improvement(mothership): streaming state transitions (#4439)

* improvement(mothership): improve streaming state transitions

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-05-04 16:52:43 -07:00
committed by GitHub
parent 1dc6f7dd09
commit 9eeb1b2cdb
10 changed files with 1883 additions and 399 deletions
@@ -29,6 +29,16 @@ const {
mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
}))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
userId: 'copilotChats.userId',
workspaceId: 'copilotChats.workspaceId',
messages: 'copilotChats.messages',
conversationId: 'copilotChats.conversationId',
},
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
@@ -140,6 +150,7 @@ describe('copilot chat stop route', () => {
workspaceId: 'ws-1',
chatId: 'chat-1',
type: 'completed',
streamId: 'stream-1',
})
})
})
@@ -111,6 +111,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
workspaceId: updated.workspaceId,
chatId,
type: 'completed',
streamId,
})
}
@@ -248,6 +248,7 @@ async function handleResumeRequestBody({
events: batchEvents,
previewSessions,
status: run.status,
...(run.chatId ? { chatId: run.chatId } : {}),
})
}
@@ -27,6 +27,7 @@ const mothershipEventsHandler = createWorkspaceSSE({
send('task_status', {
chatId: event.chatId,
type: event.type,
...(event.streamId ? { streamId: event.streamId } : {}),
timestamp: Date.now(),
})
})
File diff suppressed because it is too large Load Diff
+355 -3
View File
@@ -9,14 +9,46 @@ import { handleTaskStatusEvent } from '@/hooks/use-task-events'
describe('handleTaskStatusEvent', () => {
const queryClient = {
getQueryData: vi.fn(),
invalidateQueries: vi.fn().mockResolvedValue(undefined),
} satisfies Pick<QueryClient, 'invalidateQueries'>
removeQueries: vi.fn(),
} satisfies Pick<QueryClient, 'getQueryData' | 'invalidateQueries' | 'removeQueries'>
beforeEach(() => {
vi.clearAllMocks()
queryClient.getQueryData.mockReturnValue(undefined)
})
it('invalidates only the task list for completed task events', () => {
it('invalidates the task list and detail for completed task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps completed task detail when an unkeyed completion races an active stream', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'new-stream' }, { id: 'live-assistant:new-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
@@ -31,9 +63,225 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps list invalidation only for non-completed task events', () => {
it('keeps completed task detail when a newer optimistic stream is active', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'old-stream' }, { id: 'new-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
streamId: 'old-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps completed task detail when only a newer optimistic stream is cached', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'new-stream' }, { id: 'live-assistant:new-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
streamId: 'old-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates completed task detail when the active stream disagreement is only stale cache', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'new-stream' }, { id: 'old-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
streamId: 'old-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates completed task detail when a missing stream may be newer server state', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'old-stream' }],
activeStreamId: 'old-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
streamId: 'new-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates completed task detail when the completed stream is active', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [],
activeStreamId: 'stream-1',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
streamId: 'stream-1',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates the task list and detail for metadata-changing task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'renamed',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates the task list and removes detail cache for deleted task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'deleted',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).toHaveBeenCalledTimes(1)
expect(queryClient.removeQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
})
it('invalidates the task list and detail for started task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'started',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps started task detail when an unkeyed started event races an active stream', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'new-stream' }, { id: 'live-assistant:new-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
@@ -48,11 +296,115 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps started task detail when the started stream is already active', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'stream-1' }],
activeStreamId: 'stream-1',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'started',
streamId: 'stream-1',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps started task detail when a stale started stream is older than the active stream', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'old-stream' }, { id: 'new-stream' }],
activeStreamId: 'new-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'started',
streamId: 'old-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates started task detail when a missing stream may be newer server state', () => {
queryClient.getQueryData.mockReturnValue({
id: 'chat-1',
title: null,
messages: [{ id: 'old-stream' }],
activeStreamId: 'old-stream',
resources: [],
})
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'started',
streamId: 'new-stream',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps list invalidation only for unknown task event types', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'archived',
timestamp: Date.now(),
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('does not invalidate when task event payload is invalid', () => {
handleTaskStatusEvent(queryClient, 'ws-1', '{')
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
})
+74 -6
View File
@@ -2,13 +2,68 @@ import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import type { QueryClient } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { taskKeys } from '@/hooks/queries/tasks'
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
import { type TaskChatHistory, taskKeys } from '@/hooks/queries/tasks'
const logger = createLogger('TaskEvents')
const TASK_STATUS_TYPES = ['started', 'completed', 'created', 'deleted', 'renamed'] as const
type TaskStatusEventType = (typeof TASK_STATUS_TYPES)[number]
const TASK_STATUS_TYPE_SET = new Set<string>(TASK_STATUS_TYPES)
interface TaskStatusEventPayload {
chatId?: string
type?: 'started' | 'completed' | 'created' | 'deleted' | 'renamed'
type?: TaskStatusEventType
streamId?: string
}
const DETAIL_INVALIDATING_TASK_STATUS_TYPES = new Set<TaskStatusEventType>([
'started',
'completed',
'renamed',
])
function isTaskStatusEventType(value: unknown): value is TaskStatusEventType {
return typeof value === 'string' && TASK_STATUS_TYPE_SET.has(value)
}
function isLocalOptimisticActiveStream(current: TaskChatHistory | undefined) {
if (!current?.activeStreamId) return false
const liveAssistantId = getLiveAssistantMessageId(current.activeStreamId)
return current.messages.some((message) => message.id === liveAssistantId)
}
/**
* Returns true when the cached active stream is known to be later in the
* chronological transcript than the stream that emitted this status event.
* If either stream is absent from the transcript, callers should refetch
* instead of inferring order from incomplete cache state.
*/
function hasNewerKnownActiveStream(current: TaskChatHistory | undefined, streamId: string) {
if (!current?.activeStreamId || current.activeStreamId === streamId) return false
const activeIndex = current.messages.findIndex((message) => message.id === current.activeStreamId)
const eventStreamIndex = current.messages.findIndex((message) => message.id === streamId)
if (activeIndex === -1) return false
if (eventStreamIndex === -1) return false
return activeIndex > eventStreamIndex
}
function shouldSkipDetailInvalidationForStreamEvent(
current: TaskChatHistory | undefined,
payload: TaskStatusEventPayload
) {
if (payload.type !== 'started' && payload.type !== 'completed') return false
if (!current?.activeStreamId) return false
if (!payload.streamId) return isLocalOptimisticActiveStream(current)
if (payload.type === 'started' && current.activeStreamId === payload.streamId) return true
if (current.activeStreamId === payload.streamId) return false
if (hasNewerKnownActiveStream(current, payload.streamId)) return true
return (
payload.type === 'completed' &&
isLocalOptimisticActiveStream(current) &&
!current.messages.some((message) => message.id === payload.streamId)
)
}
function parseTaskStatusEventPayload(data: unknown): TaskStatusEventPayload | null {
@@ -30,14 +85,13 @@ function parseTaskStatusEventPayload(data: unknown): TaskStatusEventPayload | nu
return {
...(typeof record.chatId === 'string' ? { chatId: record.chatId } : {}),
...(typeof record.type === 'string'
? { type: record.type as TaskStatusEventPayload['type'] }
: {}),
...(isTaskStatusEventType(record.type) ? { type: record.type } : {}),
...(typeof record.streamId === 'string' ? { streamId: record.streamId } : {}),
}
}
export function handleTaskStatusEvent(
queryClient: Pick<QueryClient, 'invalidateQueries'>,
queryClient: Pick<QueryClient, 'getQueryData' | 'invalidateQueries' | 'removeQueries'>,
workspaceId: string,
data: unknown
): void {
@@ -48,6 +102,20 @@ export function handleTaskStatusEvent(
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
if (!payload.chatId) return
if (payload.type === 'deleted') {
queryClient.removeQueries({ queryKey: taskKeys.detail(payload.chatId) })
return
}
if (payload.type === 'started' || payload.type === 'completed') {
const current = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(payload.chatId))
if (shouldSkipDetailInvalidationForStreamEvent(current, payload)) {
return
}
}
if (payload.type && DETAIL_INVALIDATING_TASK_STATUS_TYPES.has(payload.type)) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(payload.chatId) })
}
}
/**
+3
View File
@@ -329,6 +329,7 @@ async function persistUserMessage(params: {
workspaceId,
chatId,
type: 'started',
streamId: userMessageId,
})
}
@@ -430,6 +431,7 @@ function buildOnComplete(params: {
workspaceId,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
} catch (error) {
@@ -461,6 +463,7 @@ function buildOnError(params: {
workspaceId,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
} catch (error) {
+1
View File
@@ -13,6 +13,7 @@ interface TaskStatusEvent {
workspaceId: string
chatId: string
type: 'started' | 'completed' | 'created' | 'deleted' | 'renamed'
streamId?: string
}
const channel =
+4 -1
View File
@@ -131,11 +131,14 @@ export async function executeInboxTask(taskId: string): Promise<void> {
})
}
const userMessageId = generateId()
if (chatId) {
taskPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'started',
streamId: userMessageId,
})
}
@@ -178,7 +181,6 @@ export async function executeInboxTask(taskId: string): Promise<void> {
}
const messageContent = formatEmailAsMessage(truncatedTask, attachments)
const userMessageId = generateId()
const requestPayload: Record<string, unknown> = {
message: messageContent,
userId,
@@ -244,6 +246,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
workspaceId: ws.id,
chatId,
type: 'completed',
streamId: userMessageId,
})
}