Skip to content

Commit f3924ce

Browse files
committed
improvement(chat): stop reloading the full transcript on every turn and event
1 parent a0c93d6 commit f3924ce

11 files changed

Lines changed: 378 additions & 184 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/hooks/use-mothership-chat-events.test.ts‎

Lines changed: 72 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,75 @@ 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 transcript when a stream this viewer is not rendering completes', async () => {
227+
const { queryClient, fetchTranscript, unsubscribe } = mountDetail({
228+
...liveStream,
229+
messages: [{ id: 'stream-1' }] as MothershipChatHistory['messages'],
230+
})
231+
232+
handleMothershipChatStatusEvent(queryClient, 'ws-1', {
233+
chatId: 'chat-1',
234+
type: 'completed',
235+
streamId: 'stream-1',
236+
})
237+
await vi.waitFor(() => expect(fetchTranscript).toHaveBeenCalledTimes(1))
238+
unsubscribe()
239+
})
240+
241+
it('marks the detail stale on rename without reloading the transcript', async () => {
242+
const { queryClient, fetchTranscript, unsubscribe } = mountDetail({
243+
...liveStream,
244+
messages: [],
245+
activeStreamId: null,
246+
})
247+
248+
handleMothershipChatStatusEvent(queryClient, 'ws-1', { chatId: 'chat-1', type: 'renamed' })
249+
await sleep(0)
250+
251+
expect(fetchTranscript).not.toHaveBeenCalled()
252+
expect(queryClient.getQueryState(mothershipChatKeys.detail('chat-1'))?.isInvalidated).toBe(true)
253+
unsubscribe()
254+
})
255+
})
256+
187257
describe('resyncMothershipChatCaches', () => {
188258
const queryClient = {
189259
invalidateQueries: vi.fn().mockResolvedValue(undefined),

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

Lines changed: 26 additions & 19 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
}
@@ -65,15 +59,22 @@ function hasNewerKnownActiveStream(current: MothershipChatHistory | undefined, s
6559
return activeIndex > eventStreamIndex
6660
}
6761

62+
/**
63+
* Returns true when refetching the chat detail for a stream event would only
64+
* reload the transcript this client already holds or is about to reload. A
65+
* completion of the viewer's own live stream is skipped because the server
66+
* persists the turn before closing that stream, and the client's own
67+
* finalization refetches the detail once it sees the close.
68+
*/
6869
function shouldSkipDetailInvalidationForStreamEvent(
6970
current: MothershipChatHistory | undefined,
7071
payload: ChatStatusEventPayload
7172
) {
72-
if (payload.type !== 'started' && payload.type !== 'completed') return false
7373
if (!current?.activeStreamId) return false
7474
if (!payload.streamId) return isLocalOptimisticActiveStream(current)
75-
if (payload.type === 'started' && current.activeStreamId === payload.streamId) return true
76-
if (current.activeStreamId === payload.streamId) return false
75+
if (current.activeStreamId === payload.streamId) {
76+
return payload.type === 'started' || isLocalOptimisticActiveStream(current)
77+
}
7778
if (hasNewerKnownActiveStream(current, payload.streamId)) return true
7879
return (
7980
payload.type === 'completed' &&
@@ -128,17 +129,23 @@ export function handleMothershipChatStatusEvent(
128129
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
129130
return
130131
}
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) })
132+
if (payload.type === 'renamed') {
133+
/**
134+
* The lists invalidated above carry the title every surface renders; the
135+
* detail only needs marking stale, not a full transcript reload.
136+
*/
137+
queryClient.invalidateQueries({
138+
queryKey: mothershipChatKeys.detail(payload.chatId),
139+
refetchType: 'none',
140+
})
141+
return
141142
}
143+
if (payload.type !== 'started' && payload.type !== 'completed') return
144+
const current = queryClient.getQueryData<MothershipChatHistory>(
145+
mothershipChatKeys.detail(payload.chatId)
146+
)
147+
if (shouldSkipDetailInvalidationForStreamEvent(current, payload)) return
148+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
142149
}
143150

144151
/**

0 commit comments

Comments
 (0)