Skip to content

Commit f76765f

Browse files
committed
fix(mothership): only skip resync for a stream still running
Optimistic markers alone were the skip condition, but a finished turn can leave activeStreamId and its live-assistant message cached when finalization skips detail invalidation for a queued follow-up. That chat would then be excluded from every future resync — permanently, since only a refetch clears the markers, and the resync was the refetch. Gate the skip on a non-terminal streamSnapshot status so it covers turns that are genuinely still streaming. Exports isTerminalStreamStatus, which was already the private check for this in effective-transcript.
1 parent 70541f4 commit f76765f

3 files changed

Lines changed: 42 additions & 8 deletions

File tree

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,15 +453,29 @@ describe('resyncMothershipChatCaches', () => {
453453
)
454454
})
455455

456-
it('skips the detail of a chat whose stream this client is rendering locally', () => {
456+
it('skips the detail of a chat this client is still streaming', () => {
457457
expect(
458458
detailPredicate()({
459459
messages: [{ id: 'new-stream' }, { id: 'live-assistant:new-stream' }],
460460
activeStreamId: 'new-stream',
461+
streamSnapshot: { events: [], previewSessions: [], status: 'streaming' },
461462
})
462463
).toBe(false)
463464
})
464465

466+
it('invalidates a chat whose stream finished but left its optimistic markers cached', () => {
467+
const predicate = detailPredicate()
468+
const finished = (status: string) => ({
469+
messages: [{ id: 'new-stream' }, { id: 'live-assistant:new-stream' }],
470+
activeStreamId: 'new-stream',
471+
streamSnapshot: { events: [], previewSessions: [], status },
472+
})
473+
474+
expect(predicate(finished('complete'))).toBe(true)
475+
expect(predicate(finished('error'))).toBe(true)
476+
expect(predicate(finished('cancelled'))).toBe(true)
477+
})
478+
465479
it('invalidates details with no active stream, and streams not rendered locally', () => {
466480
const predicate = detailPredicate()
467481

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { useEffect } from 'react'
22
import { createLogger } from '@sim/logger'
33
import type { QueryClient } from '@tanstack/react-query'
44
import { useQueryClient } from '@tanstack/react-query'
5-
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
5+
import {
6+
getLiveAssistantMessageId,
7+
isTerminalStreamStatus,
8+
} from '@/lib/copilot/chat/effective-transcript'
69
import { isChatEnabled } from '@/lib/core/config/env-flags'
710
import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope'
811
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
@@ -38,6 +41,23 @@ function isLocalOptimisticActiveStream(current: MothershipChatHistory | undefine
3841
return current.messages.some((message) => message.id === liveAssistantId)
3942
}
4043

44+
/**
45+
* True while this client is still rendering a stream for the chat.
46+
*
47+
* The optimistic markers alone are not enough: a finished turn can leave
48+
* `activeStreamId` and its live-assistant message in the cache (finalization
49+
* skips detail invalidation when a follow-up is queued), and treating those as
50+
* live would exclude the chat from every future resync — permanently, since
51+
* only a refetch would clear them. Requiring a non-terminal snapshot status
52+
* keeps the skip to turns that are genuinely still streaming.
53+
*/
54+
function isStreamingLocally(current: MothershipChatHistory | undefined) {
55+
return (
56+
isLocalOptimisticActiveStream(current) &&
57+
!isTerminalStreamStatus(current?.streamSnapshot?.status)
58+
)
59+
}
60+
4161
/**
4262
* Returns true when the cached active stream is known to be later in the
4363
* chronological transcript than the stream that emitted this status event.
@@ -139,10 +159,10 @@ export function handleMothershipChatStatusEvent(
139159
* delete, or completion. Invalidating the workspace lists and every chat detail
140160
* reconciles from the server; only queries that are currently mounted refetch.
141161
*
142-
* A chat whose stream this client is rendering locally is left alone, for the
143-
* same reason status events skip it: refetching mid-stream would replace the
144-
* optimistic transcript with a server copy that does not yet hold the in-flight
145-
* message. That chat reconciles when its own stream finishes.
162+
* A chat this client is still streaming is left alone, for the same reason
163+
* status events skip it: refetching mid-stream would replace the optimistic
164+
* transcript with a server copy that does not yet hold the in-flight message.
165+
* That chat reconciles when its own stream finishes.
146166
*/
147167
export function resyncMothershipChatCaches(
148168
queryClient: Pick<QueryClient, 'invalidateQueries'>,
@@ -152,7 +172,7 @@ export function resyncMothershipChatCaches(
152172
queryClient.invalidateQueries({
153173
queryKey: mothershipChatKeys.details(),
154174
predicate: (query) =>
155-
!isLocalOptimisticActiveStream(query.state.data as MothershipChatHistory | undefined),
175+
!isStreamingLocally(query.state.data as MothershipChatHistory | undefined),
156176
})
157177
}
158178

apps/sim/lib/copilot/chat/effective-transcript.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
5252
return isRecordLike(value) ? value : undefined
5353
}
5454

55-
function isTerminalStreamStatus(status: string | null | undefined): boolean {
55+
export function isTerminalStreamStatus(status: string | null | undefined): boolean {
5656
return (
5757
status === MothershipStreamV1CompletionStatus.complete ||
5858
status === MothershipStreamV1CompletionStatus.error ||

0 commit comments

Comments
 (0)