Skip to content

Commit 5a69768

Browse files
authored
improvement(chat): stop reloading the full transcript on every turn and event (#8328)
- Each chat turn loaded the chat's full transcript through `resolveOrCreateChat` just to read the MCP server ids earlier user messages were tagged with. `resolveOrCreateChat` now never loads the transcript, and a new `loadChatMcpServerIds` reads only the MCP context ids with one jsonb query, in the same first-tagged order - Split the chat loaders: `getAccessibleCopilotChatDetail` (row plus authorization, no transcript) backs both `resolveOrCreateChat` and `getAccessibleCopilotChatWithMessages`. Dropped the `includeTranscript` option and the `conversationHistory` field, which nothing read - Client: a `completed` event for the viewer's own live stream no longer refetches the chat detail, because the client's own stream finalization already refetches it. `renamed` marks the detail stale without refetching; the lists that show titles still refetch
1 parent 48a13cb commit 5a69768

12 files changed

Lines changed: 465 additions & 182 deletions

File tree

‎apps/sim/app/api/v2/chat/route.test.ts‎

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,6 @@ describe('POST /api/v2/chat', () => {
267267
mockResolveOrCreateChat.mockResolvedValue({
268268
chatId: SERVER_ISSUED_CHAT_ID,
269269
chat: chatRow(SERVER_ISSUED_CHAT_ID),
270-
conversationHistory: [],
271270
isNew: true,
272271
})
273272
})
@@ -444,7 +443,6 @@ describe('POST /api/v2/chat', () => {
444443
mockResolveOrCreateChat.mockResolvedValue({
445444
chatId: OWNED_CONVERSATION_ID,
446445
chat: chatRow(OWNED_CONVERSATION_ID),
447-
conversationHistory: [],
448446
isNew: false,
449447
})
450448

@@ -469,38 +467,10 @@ describe('POST /api/v2/chat', () => {
469467
})
470468
})
471469

472-
it('posts only the current turn on a resumed conversation, never the stored transcript', async () => {
473-
mockResolveOrCreateChat.mockResolvedValue({
474-
chatId: OWNED_CONVERSATION_ID,
475-
chat: chatRow(OWNED_CONVERSATION_ID),
476-
conversationHistory: [
477-
{ role: 'user', content: 'first' },
478-
{ role: 'assistant', content: 'first reply' },
479-
],
480-
isNew: false,
481-
})
482-
483-
const response = await callChat({
484-
workspaceId: 'workspace-1',
485-
message: 'and then?',
486-
conversationId: OWNED_CONVERSATION_ID,
487-
})
488-
489-
expect(response.status).toBe(200)
490-
// Continuity is keyed by chatId downstream, exactly as the web send path
491-
// and the Sim Chat block do. Replaying the transcript here would duplicate
492-
// every prior turn.
493-
expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({
494-
message: 'and then?',
495-
chatId: OWNED_CONVERSATION_ID,
496-
})
497-
})
498-
499470
it('answers 404 and runs nothing when the resolver refuses the named conversation', async () => {
500471
mockResolveOrCreateChat.mockResolvedValue({
501472
chatId: OWNED_CONVERSATION_ID,
502473
chat: null,
503-
conversationHistory: [],
504474
isNew: false,
505475
})
506476

‎apps/sim/app/api/v2/chat/route.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,11 @@ export const POST = withRouteHandler(
264264
// surface uses, and refuse every id that does not resolve with the same
265265
// response so the refusal carries no information about the id. Omitting
266266
// the id mints a server-issued conversation instead of trusting one.
267-
// The resolved transcript is deliberately not forwarded: continuity is
268-
// keyed by `chatId` downstream, exactly as the web send path and the Sim
269-
// Chat block do, both of which post a single message with a chat id.
267+
// Continuity is keyed by `chatId` downstream, exactly as the web send
268+
// path and the Sim Chat block do, both of which post a single message
269+
// with a chat id.
270270
const resolvedChat = await resolveOrCreateChat({
271271
...(conversationId ? { chatId: conversationId } : {}),
272-
includeTranscript: false,
273272
userId,
274273
workspaceId,
275274
model: MOTHERSHIP_CHAT_DEFAULT_MODEL,

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
} from '@/app/workspace/[workspaceId]/home/hooks/send-handoff'
8383
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
8484
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
85+
import { handleMothershipChatStatusEvent } from '@/hooks/use-mothership-chat-events'
8586
import { useExecutionStore } from '@/stores/execution/store'
8687
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
8788

@@ -1821,4 +1822,81 @@ describe('useChat remount send recovery', () => {
18211822
expect(getResult().messageQueue.map((entry) => entry.id)).toEqual(['unsent-entry'])
18221823
expect(state.postBodies).toHaveLength(0)
18231824
})
1825+
1826+
it('loads the saved transcript once when its own stream completes', async () => {
1827+
const chatId = 'chat-own-completion'
1828+
const history: MothershipChatHistory = {
1829+
id: chatId,
1830+
mode: 'agent',
1831+
title: 'Own stream',
1832+
messages: [],
1833+
activeStreamId: null,
1834+
resources: [],
1835+
}
1836+
const saved = [
1837+
{ id: 'saved-user', role: 'user', content: 'Summarize the run' },
1838+
{ id: 'saved-assistant', role: 'assistant', content: 'Done.' },
1839+
]
1840+
const detailRequests: string[] = []
1841+
mockRequestJson.mockImplementation((contract: AnyApiRouteContract) => {
1842+
if (contract.path !== '/api/mothership/chats/[chatId]') {
1843+
return Promise.resolve({ chats: [] })
1844+
}
1845+
detailRequests.push(chatId)
1846+
return Promise.resolve({ chat: { ...history, messages: saved } })
1847+
})
1848+
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
1849+
let streamId: string | undefined
1850+
const emit = (event: Omit<MothershipStreamV1EventEnvelope, 'v' | 'ts' | 'stream'>) =>
1851+
stream?.enqueue(
1852+
new TextEncoder().encode(
1853+
`data: ${JSON.stringify({ v: 1, ts: '', stream: { streamId }, ...event })}\n\n`
1854+
)
1855+
)
1856+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
1857+
if (String(input) !== '/api/mothership/chat' || init?.method !== 'POST') {
1858+
return fetchStub(input, init)
1859+
}
1860+
streamId = JSON.parse(String(init.body)).userMessageId
1861+
return new Response(
1862+
new ReadableStream<Uint8Array>({
1863+
start(controller) {
1864+
stream = controller
1865+
},
1866+
}),
1867+
{ headers: { 'Content-Type': 'text/event-stream', 'x-mothership-chat-id': chatId } }
1868+
)
1869+
})
1870+
const { getResult } = renderUseChatInChat(chatId, history)
1871+
1872+
await act(async () => {
1873+
void getResult().sendMessage('Summarize the run')
1874+
})
1875+
await waitFor(() => stream !== undefined)
1876+
emit({ seq: 1, type: 'text', payload: { channel: 'assistant', text: 'Done.' } })
1877+
await waitFor(
1878+
() =>
1879+
queryClient.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId))
1880+
?.activeStreamId === streamId
1881+
)
1882+
/** The server publishes `completed` after persisting and before closing the stream. */
1883+
handleMothershipChatStatusEvent(queryClient, 'ws-1', {
1884+
chatId,
1885+
type: 'completed',
1886+
streamId,
1887+
})
1888+
emit({ seq: 2, type: 'complete', payload: { status: 'complete' } })
1889+
stream?.close()
1890+
1891+
await waitFor(() => !getResult().isSending && detailRequests.length > 0)
1892+
await act(async () => {
1893+
await sleep(50)
1894+
})
1895+
expect(detailRequests).toHaveLength(1)
1896+
expect(
1897+
queryClient
1898+
.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId))
1899+
?.messages.map((message) => message.id)
1900+
).toEqual(['saved-user', 'saved-assistant'])
1901+
})
18241902
})

‎apps/sim/hooks/use-mothership-chat-events.test.ts‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { QueryClient } from '@tanstack/react-query'
1+
import { sleep } from '@sim/utils/helpers'
2+
import { QueryClient, QueryObserver } from '@tanstack/react-query'
23
import { beforeEach, describe, expect, it, vi } from 'vitest'
34

45
const { suspendBrowserScope, suspendTerminalScope } = vi.hoisted(() => ({
@@ -9,7 +10,7 @@ const { suspendBrowserScope, suspendTerminalScope } = vi.hoisted(() => ({
910
vi.mock('@/lib/browser-agent/transport', () => ({ suspendBrowserScope }))
1011
vi.mock('@/lib/terminal/transport', () => ({ suspendTerminalScope }))
1112

12-
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
13+
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
1314
import {
1415
handleMothershipChatStatusEvent,
1516
resyncMothershipChatCaches,
@@ -184,6 +185,80 @@ describe('handleMothershipChatStatusEvent', () => {
184185
)
185186
})
186187

188+
describe('chat detail refetches driven by status events', () => {
189+
function mountDetail(cached: MothershipChatHistory) {
190+
const queryClient = new QueryClient()
191+
const fetchTranscript = vi.fn(async () => cached)
192+
queryClient.setQueryData(mothershipChatKeys.detail('chat-1'), cached)
193+
const unsubscribe = new QueryObserver(queryClient, {
194+
queryKey: mothershipChatKeys.detail('chat-1'),
195+
queryFn: fetchTranscript,
196+
staleTime: Number.POSITIVE_INFINITY,
197+
}).subscribe(() => {})
198+
return { queryClient, fetchTranscript, unsubscribe }
199+
}
200+
201+
const liveStream: MothershipChatHistory = {
202+
id: 'chat-1',
203+
title: null,
204+
messages: [
205+
{ id: 'stream-1' },
206+
{ id: 'live-assistant:stream-1' },
207+
] as MothershipChatHistory['messages'],
208+
activeStreamId: 'stream-1',
209+
resources: [],
210+
}
211+
212+
it('does not reload the transcript when the viewer finishes its own live stream', async () => {
213+
const { queryClient, fetchTranscript, unsubscribe } = mountDetail(liveStream)
214+
215+
handleMothershipChatStatusEvent(queryClient, 'ws-1', {
216+
chatId: 'chat-1',
217+
type: 'completed',
218+
streamId: 'stream-1',
219+
})
220+
await sleep(0)
221+
222+
expect(fetchTranscript).not.toHaveBeenCalled()
223+
unsubscribe()
224+
})
225+
226+
it('reloads the saved transcript when a cached mid-stream detail is opened after completion', async () => {
227+
const queryClient = new QueryClient()
228+
const fetchTranscript = vi.fn(async () => ({ ...liveStream, activeStreamId: null }))
229+
queryClient.setQueryData(mothershipChatKeys.detail('chat-1'), liveStream)
230+
231+
handleMothershipChatStatusEvent(queryClient, 'ws-1', {
232+
chatId: 'chat-1',
233+
type: 'completed',
234+
streamId: 'stream-1',
235+
})
236+
const unsubscribe = new QueryObserver(queryClient, {
237+
queryKey: mothershipChatKeys.detail('chat-1'),
238+
queryFn: fetchTranscript,
239+
staleTime: Number.POSITIVE_INFINITY,
240+
}).subscribe(() => {})
241+
242+
await vi.waitFor(() => expect(fetchTranscript).toHaveBeenCalledTimes(1))
243+
unsubscribe()
244+
})
245+
246+
it('marks the detail stale on rename without reloading the transcript', async () => {
247+
const { queryClient, fetchTranscript, unsubscribe } = mountDetail({
248+
...liveStream,
249+
messages: [],
250+
activeStreamId: null,
251+
})
252+
253+
handleMothershipChatStatusEvent(queryClient, 'ws-1', { chatId: 'chat-1', type: 'renamed' })
254+
await sleep(0)
255+
256+
expect(fetchTranscript).not.toHaveBeenCalled()
257+
expect(queryClient.getQueryState(mothershipChatKeys.detail('chat-1'))?.isInvalidated).toBe(true)
258+
unsubscribe()
259+
})
260+
})
261+
187262
describe('resyncMothershipChatCaches', () => {
188263
const queryClient = {
189264
invalidateQueries: vi.fn().mockResolvedValue(undefined),

‎apps/sim/hooks/use-mothership-chat-events.ts‎

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,6 @@ interface ChatStatusEventPayload {
3333
streamId?: string
3434
}
3535

36-
const DETAIL_INVALIDATING_CHAT_STATUS_TYPES = new Set<ChatStatusEventType>([
37-
'started',
38-
'completed',
39-
'renamed',
40-
])
41-
4236
function isChatStatusEventType(value: unknown): value is ChatStatusEventType {
4337
return typeof value === 'string' && CHAT_STATUS_TYPE_SET.has(value)
4438
}
@@ -69,7 +63,6 @@ function shouldSkipDetailInvalidationForStreamEvent(
6963
current: MothershipChatHistory | undefined,
7064
payload: ChatStatusEventPayload
7165
) {
72-
if (payload.type !== 'started' && payload.type !== 'completed') return false
7366
if (!current?.activeStreamId) return false
7467
if (!payload.streamId) return isLocalOptimisticActiveStream(current)
7568
if (payload.type === 'started' && current.activeStreamId === payload.streamId) return true
@@ -128,17 +121,37 @@ export function handleMothershipChatStatusEvent(
128121
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
129122
return
130123
}
131-
if (payload.type === 'started' || payload.type === 'completed') {
132-
const current = queryClient.getQueryData<MothershipChatHistory>(
133-
mothershipChatKeys.detail(payload.chatId)
134-
)
135-
if (shouldSkipDetailInvalidationForStreamEvent(current, payload)) {
136-
return
137-
}
138-
}
139-
if (payload.type && DETAIL_INVALIDATING_CHAT_STATUS_TYPES.has(payload.type)) {
140-
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
124+
if (payload.type === 'renamed') {
125+
/**
126+
* The lists invalidated above carry the title every surface renders; the
127+
* detail only needs marking stale, not a full transcript reload.
128+
*/
129+
queryClient.invalidateQueries({
130+
queryKey: mothershipChatKeys.detail(payload.chatId),
131+
refetchType: 'none',
132+
})
133+
return
141134
}
135+
if (payload.type !== 'started' && payload.type !== 'completed') return
136+
const current = queryClient.getQueryData<MothershipChatHistory>(
137+
mothershipChatKeys.detail(payload.chatId)
138+
)
139+
if (shouldSkipDetailInvalidationForStreamEvent(current, payload)) return
140+
/**
141+
* A completion of the cached live stream only marks the detail stale. The
142+
* server persists the turn before closing the stream, so a surface rendering
143+
* it refetches through its own finalization; the live message alone cannot
144+
* tell this tab's stream from a server-loaded mid-stream snapshot, so any
145+
* other cached copy reloads the saved transcript on its next mount.
146+
*/
147+
const completesCachedLiveStream =
148+
payload.type === 'completed' &&
149+
current?.activeStreamId === payload.streamId &&
150+
isLocalOptimisticActiveStream(current)
151+
queryClient.invalidateQueries({
152+
queryKey: mothershipChatKeys.detail(payload.chatId),
153+
...(completesCachedLiveStream ? { refetchType: 'none' as const } : {}),
154+
})
142155
}
143156

144157
/**

0 commit comments

Comments
 (0)