fix(mothership): chat stream structuring + logs resource post fix (#4189)

* fix(mothership): chat streaming structure

* fix logs resource thinking bug"

* address comments

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-04-15 16:43:22 -07:00
committed by GitHub
parent 6dddc3f796
commit 377712c9f3
11 changed files with 1446 additions and 159 deletions
+14
View File
@@ -4,7 +4,9 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -113,11 +115,23 @@ export async function GET(req: NextRequest) {
}
}
const normalizedMessages = Array.isArray(chat.messages)
? chat.messages
.filter((message): message is Record<string, unknown> => Boolean(message))
.map(normalizeMessage)
: []
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: chat.conversationId || null,
...(streamSnapshot ? { streamSnapshot } : {}),
})
logger.info(`Retrieved chat ${chatId}`)
return NextResponse.json({
success: true,
chat: {
...transformChat(chat),
messages: effectiveMessages,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
@@ -0,0 +1,160 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockSelect,
mockFrom,
mockWhereSelect,
mockLimit,
mockUpdate,
mockSet,
mockWhereUpdate,
mockReturning,
mockPublishStatusChanged,
mockSql,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhereSelect: vi.fn(),
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockWhereUpdate: vi.fn(),
mockReturning: vi.fn(),
mockPublishStatusChanged: vi.fn(),
mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
},
}))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
messages: 'messages',
conversationId: 'conversationId',
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
sql: mockSql,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: {
publishStatusChanged: mockPublishStatusChanged,
},
}))
import { POST } from '@/app/api/copilot/chat/stop/route'
function createRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost:3000/api/copilot/chat/stop', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('copilot chat stop route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockLimit.mockResolvedValue([
{
workspaceId: 'ws-1',
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
},
])
mockWhereSelect.mockReturnValue({ limit: mockLimit })
mockFrom.mockReturnValue({ where: mockWhereSelect })
mockSelect.mockReturnValue({ from: mockFrom })
mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }])
mockWhereUpdate.mockReturnValue({ returning: mockReturning })
mockSet.mockReturnValue({ where: mockWhereUpdate })
mockUpdate.mockReturnValue({ set: mockSet })
})
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValueOnce(null)
const response = await POST(
createRequest({
chatId: 'chat-1',
streamId: 'stream-1',
content: '',
})
)
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'Unauthorized' })
})
it('is a no-op when the chat is missing', async () => {
mockLimit.mockResolvedValueOnce([])
const response = await POST(
createRequest({
chatId: 'missing-chat',
streamId: 'stream-1',
content: '',
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(mockUpdate).not.toHaveBeenCalled()
})
it('appends a stopped assistant message even with no content', async () => {
const response = await POST(
createRequest({
chatId: 'chat-1',
streamId: 'stream-1',
content: '',
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
const setArg = mockSet.mock.calls[0]?.[0]
expect(setArg).toBeTruthy()
expect(setArg.conversationId).toBeNull()
expect(setArg.messages).toBeTruthy()
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
expect(appendedPayload).toHaveLength(1)
expect(appendedPayload[0]).toMatchObject({
role: 'assistant',
content: '',
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
})
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
workspaceId: 'ws-1',
chatId: 'chat-1',
type: 'completed',
})
})
})
+9 -5
View File
@@ -7,6 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import { taskPubSub } from '@/lib/copilot/tasks'
import { generateId } from '@/lib/core/utils/uuid'
const logger = createLogger('CopilotChatStopAPI')
@@ -70,7 +71,6 @@ export async function POST(req: NextRequest) {
}
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
const [row] = await db
.select({
workspaceId: copilotChats.workspaceId,
@@ -106,14 +106,18 @@ export async function POST(req: NextRequest) {
const hasContent = content.trim().length > 0
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0
if ((hasContent || hasBlocks) && canAppendAssistant) {
const synthesizedStoppedBlocks = hasBlocks
? contentBlocks
: hasContent
? [{ type: 'text', channel: 'assistant', content }, { type: 'stopped' }]
: [{ type: 'stopped' }]
if (canAppendAssistant) {
const normalized = normalizeMessage({
id: crypto.randomUUID(),
id: generateId(),
role: 'assistant',
content,
timestamp: new Date().toISOString(),
...(hasBlocks ? { contentBlocks } : {}),
contentBlocks: synthesizedStoppedBlocks,
})
const assistantMessage: PersistedMessage = normalized
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
@@ -5,7 +5,9 @@ import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -93,12 +95,23 @@ export async function GET(
}
}
const normalizedMessages = Array.isArray(chat.messages)
? chat.messages
.filter((message): message is Record<string, unknown> => Boolean(message))
.map(normalizeMessage)
: []
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: chat.conversationId || null,
...(streamSnapshot ? { streamSnapshot } : {}),
})
return NextResponse.json({
success: true,
chat: {
id: chat.id,
title: chat.title,
messages: Array.isArray(chat.messages) ? chat.messages : [],
messages: effectiveMessages,
conversationId: chat.conversationId || null,
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
@@ -3,16 +3,20 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
import type {
PersistedFileAttachment,
PersistedMessage,
} from '@/lib/copilot/chat/persisted-message'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
import type {
MothershipStreamV1ErrorPayload,
MothershipStreamV1ToolUI,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
MothershipStreamV1RunKind,
@@ -172,6 +176,8 @@ const RECONNECT_TAIL_ERROR =
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
const QUEUED_SEND_HANDOFF_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff`
const QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff-claim`
const logger = createLogger('useChat')
@@ -188,6 +194,100 @@ type ActiveTurn = {
optimisticAssistantMessage: ChatMessage
}
interface QueuedSendHandoffState {
id: string
chatId: string
workspaceId: string
supersededStreamId: string | null
userMessageId: string
message: string
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
requestedAt: number
}
interface QueuedSendHandoffSeed {
id: string
chatId: string
supersededStreamId: string | null
userMessageId?: string
}
function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<QueuedSendHandoffState>
if (
typeof parsed?.id !== 'string' ||
typeof parsed.chatId !== 'string' ||
typeof parsed.workspaceId !== 'string' ||
typeof parsed.userMessageId !== 'string' ||
typeof parsed.message !== 'string' ||
typeof parsed.requestedAt !== 'number'
) {
return null
}
return {
id: parsed.id,
chatId: parsed.chatId,
workspaceId: parsed.workspaceId,
supersededStreamId:
typeof parsed.supersededStreamId === 'string' ? parsed.supersededStreamId : null,
userMessageId: parsed.userMessageId,
message: parsed.message,
...(Array.isArray(parsed.fileAttachments)
? { fileAttachments: parsed.fileAttachments as FileAttachmentForApi[] }
: {}),
...(Array.isArray(parsed.contexts) ? { contexts: parsed.contexts as ChatContext[] } : {}),
requestedAt: parsed.requestedAt,
}
} catch {
return null
}
}
function writeQueuedSendHandoffState(state: QueuedSendHandoffState) {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(QUEUED_SEND_HANDOFF_STORAGE_KEY, JSON.stringify(state))
}
function clearQueuedSendHandoffState(expectedId?: string) {
if (typeof window === 'undefined') return
if (expectedId) {
const current = readQueuedSendHandoffState()
if (current && current.id !== expectedId) {
return
}
}
window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_STORAGE_KEY)
}
function readQueuedSendHandoffClaim(): string | null {
if (typeof window === 'undefined') return null
return window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY)
}
function writeQueuedSendHandoffClaim(id: string) {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY, id)
}
function clearQueuedSendHandoffClaim(expectedId?: string) {
if (typeof window === 'undefined') return
if (expectedId) {
const current = readQueuedSendHandoffClaim()
if (current && current !== expectedId) {
return
}
}
window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY)
}
function stringParam(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
@@ -594,6 +694,122 @@ function parseStreamBatchResponse(value: unknown): StreamBatchResponse {
}
}
function toRawPersistedContentBlock(block: ContentBlock): Record<string, unknown> | null {
switch (block.type) {
case 'text':
return {
type: MothershipStreamV1EventType.text,
...(block.subagent ? { lane: 'subagent' } : {}),
content: block.content ?? '',
}
case 'tool_call':
if (!block.toolCall) {
return null
}
return {
type: MothershipStreamV1EventType.tool,
phase: MothershipStreamV1ToolPhase.call,
toolCall: {
id: block.toolCall.id,
name: block.toolCall.name,
state: block.toolCall.status,
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),
...(block.toolCall.displayTitle
? {
display: {
title: block.toolCall.displayTitle,
},
}
: {}),
},
}
case 'subagent':
return {
type: MothershipStreamV1EventType.span,
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: block.content ?? '',
}
case 'subagent_end':
return {
type: MothershipStreamV1EventType.span,
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.end,
}
case 'stopped':
return {
type: MothershipStreamV1EventType.complete,
status: MothershipStreamV1CompletionStatus.cancelled,
}
default:
return null
}
}
function buildAssistantSnapshotMessage(params: {
id: string
content: string
contentBlocks: ContentBlock[]
requestId?: string
}): PersistedMessage {
const rawContentBlocks = params.contentBlocks
.map(toRawPersistedContentBlock)
.filter((block): block is Record<string, unknown> => block !== null)
return normalizeMessage({
id: params.id,
role: 'assistant',
content: params.content,
timestamp: new Date().toISOString(),
...(params.requestId ? { requestId: params.requestId } : {}),
...(rawContentBlocks.length > 0 ? { contentBlocks: rawContentBlocks } : {}),
})
}
function markMessageStopped(message: PersistedMessage): PersistedMessage {
if (!message.contentBlocks?.some((block) => block.toolCall?.state === 'executing')) {
return message
}
const nextBlocks = message.contentBlocks.map((block) => {
if (block.toolCall?.state !== 'executing') {
return block
}
return {
...block,
toolCall: {
...block.toolCall,
state: 'cancelled' as const,
display: {
...(block.toolCall.display ?? {}),
title: 'Stopped by user',
},
},
}
})
if (
!nextBlocks.some(
(block) =>
block.type === MothershipStreamV1EventType.complete &&
block.status === MothershipStreamV1CompletionStatus.cancelled
)
) {
nextBlocks.push({
type: MothershipStreamV1EventType.complete,
status: MothershipStreamV1CompletionStatus.cancelled,
})
}
return normalizeMessage({
...message,
contentBlocks: nextBlocks,
})
}
function buildChatHistoryHydrationKey(chatHistory: TaskChatHistory): string {
const resourceKey = chatHistory.resources
.map((resource) => `${resource.type}:${resource.id}:${resource.title}`)
@@ -667,22 +883,10 @@ function resolveLiveToolStatus(
payload: Partial<{
status: string
success: boolean
output: unknown
}>
): ToolCallStatus {
switch (payload.status) {
case MothershipStreamV1ToolOutcome.success:
return ToolCallStatus.success
case MothershipStreamV1ToolOutcome.error:
return ToolCallStatus.error
case MothershipStreamV1ToolOutcome.cancelled:
return ToolCallStatus.cancelled
case MothershipStreamV1ToolOutcome.skipped:
return ToolCallStatus.skipped
case MothershipStreamV1ToolOutcome.rejected:
return ToolCallStatus.rejected
default:
return payload.success === true ? ToolCallStatus.success : ToolCallStatus.error
}
return resolveStreamToolOutcome(payload) as ToolCallStatus
}
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
@@ -808,7 +1012,7 @@ export function useChat(
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [isReconnecting, setIsReconnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -855,6 +1059,22 @@ export function useChat(
const activeResourceIdRef = useRef(effectiveActiveResourceId)
activeResourceIdRef.current = effectiveActiveResourceId
const upsertTaskChatHistory = useCallback(
(chatId: string, updater: (current: TaskChatHistory) => TaskChatHistory) => {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (current) => {
const base: TaskChatHistory = current ?? {
id: chatId,
title: null,
messages: [],
activeStreamId: null,
resources: resourcesRef.current,
}
return updater(base)
})
},
[queryClient]
)
const {
previewSession,
previewSessionsById,
@@ -975,6 +1195,7 @@ export function useChat(
(opts: { streamId: string; assistantId: string; gen: number }) => Promise<boolean>
>(async () => false)
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const recoveringQueuedSendHandoffIdRef = useRef<string | null>(null)
const resetEphemeralPreviewState = useCallback(
(options?: { removeStreamingResource?: boolean }) => {
@@ -1101,7 +1322,7 @@ export function useChat(
setResolvedChatId(undefined)
appliedChatHistoryKeyRef.current = undefined
abortControllerRef.current = null
setMessages([])
setPendingMessages([])
setError(null)
setTransportIdle()
setResources([])
@@ -1111,38 +1332,11 @@ export function useChat(
clearQueueDispatchState()
}, [clearActiveTurn, clearQueueDispatchState, resetEphemeralPreviewState, setTransportIdle])
const mergeServerMessagesWithActiveTurn = useCallback(
(serverMessages: ChatMessage[], previousMessages: ChatMessage[]) => {
const activeTurn = activeTurnRef.current
if (!activeTurn || !sendingRef.current) {
return serverMessages
}
const nextMessages = [...serverMessages]
const localStreamingUser =
previousMessages.find(
(message) => message.id === activeTurn.userMessageId && message.role === 'user'
) ?? activeTurn.optimisticUserMessage
const localStreamingAssistant =
previousMessages.find(
(message) => message.id === activeTurn.assistantMessageId && message.role === 'assistant'
) ?? activeTurn.optimisticAssistantMessage
if (!nextMessages.some((message) => message.id === localStreamingUser.id)) {
nextMessages.push(localStreamingUser)
}
if (!nextMessages.some((message) => message.id === localStreamingAssistant.id)) {
nextMessages.push(localStreamingAssistant)
}
return nextMessages
},
[]
const { data: chatHistory } = useChatHistory(resolvedChatId)
const messages = useMemo(
() => chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages,
[chatHistory, pendingMessages]
)
const { data: chatHistory } = useChatHistory(initialChatId)
const addResource = useCallback((resource: MothershipResource): boolean => {
if (resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id)) {
return false
@@ -1268,12 +1462,12 @@ export function useChat(
)
useEffect(() => {
const streamOwnerId = chatIdRef.current
const navigatedToDifferentChat =
sendingRef.current &&
initialChatId !== streamOwnerId &&
(initialChatId !== undefined || streamOwnerId !== undefined)
if (sendingRef.current) {
const streamOwnerId = chatIdRef.current
const navigatedToDifferentChat =
initialChatId !== streamOwnerId &&
(initialChatId !== undefined || streamOwnerId !== undefined)
if (navigatedToDifferentChat) {
const abandonedChatId = streamOwnerId
// Detach the current UI from the old stream without cancelling it on the server.
@@ -1296,7 +1490,7 @@ export function useChat(
clearActiveTurn()
setResolvedChatId(initialChatId)
appliedChatHistoryKeyRef.current = undefined
setMessages([])
setPendingMessages([])
setError(null)
setTransportIdle()
setResources([])
@@ -1344,13 +1538,6 @@ export function useChat(
if (!activeStreamId && locallyTerminalStreamIdRef.current) {
locallyTerminalStreamIdRef.current = undefined
}
const shouldPreserveLocalActiveTurn = sendingRef.current && activeTurnRef.current !== null
if (shouldPreserveLocalActiveTurn) {
setMessages((prev) => mergeServerMessagesWithActiveTurn(mappedMessages, prev))
} else {
setMessages(mappedMessages)
}
void recoverPendingClientWorkflowTools(mappedMessages)
@@ -1399,7 +1586,7 @@ export function useChat(
lastCursorRef.current = '0'
setTransportReconnecting()
const assistantId = generateId()
const assistantId = getLiveAssistantMessageId(activeStreamId)
const reconnect = async () => {
const initialSnapshot = chatHistory.streamSnapshot
@@ -1458,7 +1645,6 @@ export function useChat(
queryClient,
recoverPendingClientWorkflowTools,
seedPreviewSessions,
mergeServerMessagesWithActiveTurn,
setTransportIdle,
setTransportReconnecting,
])
@@ -1569,22 +1755,41 @@ export function useChat(
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
const snapshot: Partial<ChatMessage> = {
content: runningText,
contentBlocks: [...blocks],
}
if (streamRequestId) snapshot.requestId = streamRequestId
setMessages((prev) => {
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
const activeChatId = chatIdRef.current
if (!activeChatId) {
const snapshot: Partial<ChatMessage> = {
content: runningText,
contentBlocks: [...blocks],
}
return [
...prev,
{ id: assistantId, role: 'assistant' as const, content: '', ...snapshot },
]
if (streamRequestId) snapshot.requestId = streamRequestId
setPendingMessages((prev) => {
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
}
return [
...prev,
{ id: assistantId, role: 'assistant' as const, content: '', ...snapshot },
]
})
return
}
const assistantMessage = buildAssistantSnapshotMessage({
id: assistantId,
content: runningText,
contentBlocks: blocks,
...(streamRequestId ? { requestId: streamRequestId } : {}),
})
upsertTaskChatHistory(activeChatId, (current) => ({
...current,
messages: [
...current.messages.filter((message) => message.id !== assistantId),
assistantMessage,
],
activeStreamId: streamIdRef.current ?? current.activeStreamId,
}))
}
const flushText = () => {
@@ -1690,14 +1895,23 @@ export function useChat(
const userMsg = pendingUserMsgRef.current
const activeStreamId = streamIdRef.current
if (userMsg && activeStreamId) {
const assistantMessage = buildAssistantSnapshotMessage({
id:
activeTurnRef.current?.assistantMessageId ??
getLiveAssistantMessageId(activeStreamId),
content: streamingContentRef.current,
contentBlocks: streamingBlocksRef.current,
})
const seededMessages = [userMsg, assistantMessage]
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(payloadChatId), {
id: payloadChatId,
title: null,
messages: [userMsg],
messages: seededMessages,
activeStreamId,
resources: resourcesRef.current,
})
}
setPendingMessages([])
if (!workflowIdRef.current) {
window.history.replaceState(
null,
@@ -2273,6 +2487,7 @@ export function useChat(
workspaceId,
router,
queryClient,
upsertTaskChatHistory,
addResource,
removeResource,
applyPreviewSessionUpdate,
@@ -2691,28 +2906,32 @@ export function useChat(
[]
)
const invalidateChatQueries = useCallback(() => {
const activeChatId = chatIdRef.current
if (activeChatId) {
queryClient.invalidateQueries({
queryKey: taskKeys.detail(activeChatId),
})
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
}, [workspaceId, queryClient])
const invalidateChatQueries = useCallback(
(options?: { includeDetail?: boolean }) => {
const activeChatId = chatIdRef.current
if (options?.includeDetail !== false && activeChatId) {
queryClient.invalidateQueries({
queryKey: taskKeys.detail(activeChatId),
})
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
},
[workspaceId, queryClient]
)
const messagesRef = useRef(messages)
messagesRef.current = messages
const finalize = useCallback(
(options?: { error?: boolean }) => {
const hasQueuedFollowUp = !options?.error && messageQueueRef.current.length > 0
reconcileTerminalPreviewSessions()
locallyTerminalStreamIdRef.current =
streamIdRef.current ?? activeTurnRef.current?.userMessageId ?? undefined
clearActiveTurn()
setTransportIdle()
abortControllerRef.current = null
invalidateChatQueries()
invalidateChatQueries({ includeDetail: !hasQueuedFollowUp })
if (!options?.error) {
const cid = chatIdRef.current
@@ -2725,7 +2944,7 @@ export function useChat(
return
}
if (messageQueueRef.current.length > 0) {
if (hasQueuedFollowUp) {
void enqueueQueueDispatchRef.current({ type: 'send_head' })
}
},
@@ -2738,7 +2957,9 @@ export function useChat(
message: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[],
pendingStopOverride?: Promise<void> | null
pendingStopOverride?: Promise<void> | null,
onOptimisticSendApplied?: () => void,
queuedSendHandoff?: QueuedSendHandoffSeed
) => {
if (!message.trim() || !workspaceId) return false
const pendingStop = pendingStopOverride ?? pendingStopPromiseRef.current
@@ -2750,8 +2971,8 @@ export function useChat(
setTransportStreaming()
locallyTerminalStreamIdRef.current = undefined
const userMessageId = generateId()
const assistantId = generateId()
const userMessageId = queuedSendHandoff?.userMessageId ?? generateId()
const assistantId = getLiveAssistantMessageId(userMessageId)
streamIdRef.current = userMessageId
lastCursorRef.current = '0'
@@ -2769,6 +2990,19 @@ export function useChat(
: undefined
const requestChatId = selectedChatIdRef.current ?? chatIdRef.current
if (queuedSendHandoff) {
writeQueuedSendHandoffState({
id: queuedSendHandoff.id,
chatId: queuedSendHandoff.chatId,
workspaceId,
supersededStreamId: queuedSendHandoff.supersededStreamId,
userMessageId,
message,
...(fileAttachments ? { fileAttachments } : {}),
...(contexts ? { contexts } : {}),
requestedAt: Date.now(),
})
}
const messageContexts = contexts?.map((c) => ({
kind: c.kind,
label: c.label,
@@ -2818,21 +3052,33 @@ export function useChat(
optimisticAssistantMessage,
}
if (requestChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(requestChatId) })
}
const applyOptimisticSend = () => {
const assistantSnapshot = buildAssistantSnapshotMessage({
id: assistantId,
content: '',
contentBlocks: [],
})
if (requestChatId) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(requestChatId), (old) => {
if (!old) return undefined
const nextMessages = old.messages.filter((m) => m.id !== userMessageId)
return {
...old,
resources: old.resources.filter((r) => r.id !== 'streaming-file'),
messages: [...nextMessages, cachedUserMsg],
activeStreamId: userMessageId,
}
})
upsertTaskChatHistory(requestChatId, (current) => ({
...current,
resources: current.resources.filter((resource) => resource.id !== 'streaming-file'),
messages: [
...current.messages.filter(
(persistedMessage) =>
persistedMessage.id !== userMessageId && persistedMessage.id !== assistantId
),
cachedUserMsg,
assistantSnapshot,
],
activeStreamId: userMessageId,
}))
}
setMessages((prev) => {
setPendingMessages((prev) => {
const nextMessages = prev.filter((m) => m.id !== userMessageId && m.id !== assistantId)
return [...nextMessages, optimisticUserMessage, optimisticAssistantMessage]
})
@@ -2840,20 +3086,27 @@ export function useChat(
const rollbackOptimisticSend = () => {
if (requestChatId) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(requestChatId), (old) => {
if (!old) return undefined
return {
...old,
messages: old.messages.filter((m) => m.id !== userMessageId),
activeStreamId: old.activeStreamId === userMessageId ? null : old.activeStreamId,
}
})
upsertTaskChatHistory(requestChatId, (current) => ({
...current,
messages: current.messages.filter(
(persistedMessage) =>
persistedMessage.id !== userMessageId && persistedMessage.id !== assistantId
),
activeStreamId:
current.activeStreamId === userMessageId ? null : current.activeStreamId,
}))
}
setMessages((prev) => prev.filter((m) => m.id !== userMessageId && m.id !== assistantId))
setPendingMessages((prev) =>
prev.filter(
(pendingMessage) =>
pendingMessage.id !== userMessageId && pendingMessage.id !== assistantId
)
)
}
applyOptimisticSend()
onOptimisticSendApplied?.()
consumedByTranscript = true
const abortController = new AbortController()
@@ -2863,8 +3116,9 @@ export function useChat(
if (pendingStop) {
try {
await pendingStop
// Query invalidation from the stop barrier can briefly stomp the optimistic tail.
// Re-apply it before the real POST so the mothership UI stays immediate.
if (requestChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(requestChatId) })
}
applyOptimisticSend()
} catch (err) {
rollbackOptimisticSend()
@@ -2928,6 +3182,10 @@ export function useChat(
throw new Error(errorData.error || `Request failed: ${response.status}`)
}
if (queuedSendHandoff) {
clearQueuedSendHandoffState(queuedSendHandoff.id)
}
if (!response.body) throw new Error('No response body')
const streamResult = await processSSEStream(response.body.getReader(), assistantId, gen)
@@ -2986,6 +3244,7 @@ export function useChat(
[
workspaceId,
queryClient,
upsertTaskChatHistory,
processSSEStream,
finalize,
resumeOrFinalize,
@@ -3015,6 +3274,69 @@ export function useChat(
},
[workspaceId, startSendMessage]
)
useEffect(() => {
if (typeof window === 'undefined') return
const clearClaim = () => {
clearQueuedSendHandoffClaim()
}
window.addEventListener('pagehide', clearClaim)
window.addEventListener('beforeunload', clearClaim)
return () => {
window.removeEventListener('pagehide', clearClaim)
window.removeEventListener('beforeunload', clearClaim)
}
}, [])
useEffect(() => {
if (!workspaceId || !chatHistory || sendingRef.current || pendingStopPromiseRef.current) return
const handoff = readQueuedSendHandoffState()
if (!handoff) return
if (handoff.workspaceId !== workspaceId || handoff.chatId !== chatHistory.id) return
if (recoveringQueuedSendHandoffIdRef.current === handoff.id) return
if (readQueuedSendHandoffClaim() === handoff.id) return
if (
chatHistory.activeStreamId === handoff.userMessageId ||
chatHistory.messages.some((message) => message.id === handoff.userMessageId)
) {
clearQueuedSendHandoffState(handoff.id)
clearQueuedSendHandoffClaim(handoff.id)
return
}
if (chatHistory.activeStreamId === handoff.supersededStreamId) {
return
}
if (chatHistory.activeStreamId && chatHistory.activeStreamId !== handoff.supersededStreamId) {
clearQueuedSendHandoffState(handoff.id)
clearQueuedSendHandoffClaim(handoff.id)
return
}
recoveringQueuedSendHandoffIdRef.current = handoff.id
writeQueuedSendHandoffClaim(handoff.id)
void startSendMessage(
handoff.message,
handoff.fileAttachments,
handoff.contexts,
null,
undefined,
{
id: handoff.id,
chatId: handoff.chatId,
supersededStreamId: handoff.supersededStreamId,
userMessageId: handoff.userMessageId,
}
).finally(() => {
if (recoveringQueuedSendHandoffIdRef.current === handoff.id) {
recoveringQueuedSendHandoffIdRef.current = null
}
clearQueuedSendHandoffClaim(handoff.id)
})
}, [workspaceId, chatHistory, startSendMessage])
const cancelActiveWorkflowExecutions = useCallback(() => {
const execState = useExecutionStore.getState()
const consoleStore = useTerminalConsoleStore.getState()
@@ -3066,6 +3388,7 @@ export function useChat(
}
const wasSending = sendingRef.current
const activeChatId = chatIdRef.current
const sid =
streamIdRef.current ||
activeTurnRef.current?.userMessageId ||
@@ -3088,24 +3411,36 @@ export function useChat(
abortControllerRef.current = null
setTransportIdle()
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
if (activeChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(activeChatId) })
upsertTaskChatHistory(activeChatId, (current) => ({
...current,
messages: current.messages.map(markMessageStopped),
}))
} else {
setPendingMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((block) => block.toolCall?.status === 'executing')) {
return msg
}
const updatedBlocks = msg.contentBlocks.map((block) => {
if (block.toolCall?.status !== 'executing') {
return block
}
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updatedBlocks.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updatedBlocks }
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
)
}
// Cancel active run-tool executions before waiting for the server-side stream
// shutdown barrier; otherwise the abort settle can sit behind tool execution teardown.
@@ -3175,6 +3510,7 @@ export function useChat(
persistPartialResponse,
queryClient,
resetEphemeralPreviewState,
upsertTaskChatHistory,
clearActiveTurn,
setTransportIdle,
])
@@ -3198,16 +3534,27 @@ export function useChat(
let originalIndex = 0
let removedFromQueue = false
const removeQueuedMessage = () => {
if (removedFromQueue || action.epoch !== queueDispatchEpochRef.current) {
return
}
removedFromQueue = true
setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
}
try {
const currentIndex = messageQueueRef.current.findIndex((queued) => queued.id === msg.id)
if (currentIndex !== -1) {
originalIndex = currentIndex
removedFromQueue = true
setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
}
const consumed = await startSendMessage(msg.content, msg.fileAttachments, msg.contexts)
const consumed = await startSendMessage(
msg.content,
msg.fileAttachments,
msg.contexts,
undefined,
removeQueuedMessage
)
if (!consumed && removedFromQueue && action.epoch === queueDispatchEpochRef.current) {
setMessageQueue((prev) => {
if (prev.some((queued) => queued.id === msg.id)) return prev
@@ -3250,6 +3597,8 @@ export function useChat(
enqueueQueueDispatchRef.current = enqueueQueueDispatch
const removeFromQueue = useCallback((id: string) => {
clearQueuedSendHandoffState(id)
clearQueuedSendHandoffClaim(id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
}, [])
@@ -3272,6 +3621,13 @@ export function useChat(
let originalIndex = initialIndex
let removedFromQueue = false
const removeQueuedMessage = () => {
if (removedFromQueue || epoch !== queueDispatchEpochRef.current) {
return
}
removedFromQueue = true
setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
}
const restoreQueuedMessage = () => {
if (!removedFromQueue || epoch !== queueDispatchEpochRef.current) {
return
@@ -3291,15 +3647,29 @@ export function useChat(
}
originalIndex = currentIndex
removedFromQueue = true
setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
const queuedSendHandoff =
sendingRef.current && workspaceId
? {
id: msg.id,
chatId: selectedChatIdRef.current ?? chatIdRef.current ?? '',
supersededStreamId:
streamIdRef.current ||
activeTurnRef.current?.userMessageId ||
queryClient.getQueryData<TaskChatHistory>(
taskKeys.detail(selectedChatIdRef.current ?? chatIdRef.current)
)?.activeStreamId ||
null,
}
: undefined
const pendingStop = sendingRef.current ? stopGeneration() : pendingStopPromiseRef.current
const consumed = await startSendMessage(
msg.content,
msg.fileAttachments,
msg.contexts,
pendingStop
pendingStop,
removeQueuedMessage,
queuedSendHandoff?.chatId ? queuedSendHandoff : undefined
)
if (!consumed) {
@@ -3324,6 +3694,8 @@ export function useChat(
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
const msg = messageQueueRef.current.find((m) => m.id === id)
if (!msg) return undefined
clearQueuedSendHandoffState(id)
clearQueuedSendHandoffClaim(id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
return msg
}, [])
+9 -10
View File
@@ -16,9 +16,10 @@ describe('handleTaskStatusEvent', () => {
vi.clearAllMocks()
})
it('invalidates the task list and completed chat detail', () => {
it('invalidates only the task list for completed task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'completed',
@@ -26,18 +27,16 @@ describe('handleTaskStatusEvent', () => {
})
)
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, {
queryKey: taskKeys.lists(),
})
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, {
queryKey: taskKeys.detail('chat-1'),
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
})
})
it('keeps list invalidation only for non-completed task events', () => {
handleTaskStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
chatId: 'chat-1',
type: 'started',
@@ -47,16 +46,16 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.lists(),
queryKey: taskKeys.list('ws-1'),
})
})
it('preserves list invalidation when task event payload is invalid', () => {
handleTaskStatusEvent(queryClient, '{')
handleTaskStatusEvent(queryClient, 'ws-1', '{')
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.lists(),
queryKey: taskKeys.list('ws-1'),
})
})
})
+7 -6
View File
@@ -38,19 +38,16 @@ function parseTaskStatusEventPayload(data: unknown): TaskStatusEventPayload | nu
export function handleTaskStatusEvent(
queryClient: Pick<QueryClient, 'invalidateQueries'>,
workspaceId: string,
data: unknown
): void {
queryClient.invalidateQueries({ queryKey: taskKeys.lists() })
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
const payload = parseTaskStatusEventPayload(data)
if (!payload) {
logger.warn('Received invalid task_status payload')
return
}
if (payload.type === 'completed' && payload.chatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(payload.chatId) })
}
}
/**
@@ -67,7 +64,11 @@ export function useTaskEvents(workspaceId: string | undefined) {
)
eventSource.addEventListener('task_status', (event) => {
handleTaskStatusEvent(queryClient, event instanceof MessageEvent ? event.data : undefined)
handleTaskStatusEvent(
queryClient,
workspaceId,
event instanceof MessageEvent ? event.data : undefined
)
})
eventSource.onerror = () => {
@@ -0,0 +1,263 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildEffectiveChatTranscript,
getLiveAssistantMessageId,
} from '@/lib/copilot/chat/effective-transcript'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1SessionKind,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
function toBatchEvent(eventId: number, event: StreamBatchEvent['event']): StreamBatchEvent {
return {
eventId,
streamId: event.stream.streamId,
event,
}
}
function buildUserMessage(id: string, content: string) {
return normalizeMessage({
id,
role: 'user',
content,
timestamp: '2026-04-15T12:00:00.000Z',
})
}
describe('buildEffectiveChatTranscript', () => {
it('returns the existing transcript when the stream owner is no longer the trailing user', () => {
const messages = [
buildUserMessage('stream-1', 'Hello'),
normalizeMessage({
id: 'assistant-1',
role: 'assistant',
content: 'Persisted response',
timestamp: '2026-04-15T12:00:01.000Z',
}),
]
const result = buildEffectiveChatTranscript({
messages,
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.text,
stream: { streamId: 'stream-1' },
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'Live response',
},
}),
],
previewSessions: [],
status: 'active',
},
})
expect(result).toEqual(messages)
})
it('appends a placeholder assistant while an active stream has not produced text yet', () => {
const result = buildEffectiveChatTranscript({
messages: [buildUserMessage('stream-1', 'Hello')],
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.session,
stream: { streamId: 'stream-1' },
payload: {
kind: MothershipStreamV1SessionKind.start,
},
}),
],
previewSessions: [],
status: 'active',
},
})
expect(result).toHaveLength(2)
expect(result[1]).toEqual(
expect.objectContaining({
id: getLiveAssistantMessageId('stream-1'),
role: 'assistant',
content: '',
})
)
})
it('materializes a live assistant response from redis-backed stream events', () => {
const result = buildEffectiveChatTranscript({
messages: [buildUserMessage('stream-1', 'Hello')],
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.session,
stream: { streamId: 'stream-1' },
trace: { requestId: 'req-1' },
payload: {
kind: MothershipStreamV1SessionKind.trace,
requestId: 'req-1',
},
}),
toBatchEvent(2, {
v: 1,
seq: 2,
ts: '2026-04-15T12:00:02.000Z',
type: MothershipStreamV1EventType.text,
stream: { streamId: 'stream-1' },
trace: { requestId: 'req-1' },
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'Live response',
},
}),
],
previewSessions: [],
status: 'active',
},
})
expect(result).toHaveLength(2)
expect(result[1]).toEqual(
expect.objectContaining({
id: getLiveAssistantMessageId('stream-1'),
role: 'assistant',
content: 'Live response',
requestId: 'req-1',
})
)
})
it('does not duplicate thinking-only text into a second assistant block', () => {
const result = buildEffectiveChatTranscript({
messages: [buildUserMessage('stream-1', 'Hello')],
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.text,
stream: { streamId: 'stream-1' },
payload: {
channel: MothershipStreamV1TextChannel.thinking,
text: 'Internal reasoning',
},
}),
],
previewSessions: [],
status: 'active',
},
})
expect(result).toHaveLength(2)
expect(result[1]).toEqual(
expect.objectContaining({
content: 'Internal reasoning',
contentBlocks: [
expect.objectContaining({
type: MothershipStreamV1EventType.text,
content: 'Internal reasoning',
}),
],
})
)
})
it('treats user-cancelled tool results as cancelled', () => {
const result = buildEffectiveChatTranscript({
messages: [buildUserMessage('stream-1', 'Hello')],
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.tool,
stream: { streamId: 'stream-1' },
payload: {
phase: 'result',
toolCallId: 'tool-1',
toolName: 'workspace_file',
executor: 'go',
mode: 'sync',
success: false,
output: {
reason: 'user_cancelled',
},
},
}),
],
previewSessions: [],
status: 'active',
},
})
expect(result[1]?.contentBlocks).toEqual([
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
toolCall: expect.objectContaining({
id: 'tool-1',
name: 'workspace_file',
state: MothershipStreamV1CompletionStatus.cancelled,
}),
}),
])
})
it('materializes a cancelled assistant tail when the stream ends before persistence', () => {
const result = buildEffectiveChatTranscript({
messages: [buildUserMessage('stream-1', 'Hello')],
activeStreamId: 'stream-1',
streamSnapshot: {
events: [
toBatchEvent(1, {
v: 1,
seq: 1,
ts: '2026-04-15T12:00:01.000Z',
type: MothershipStreamV1EventType.complete,
stream: { streamId: 'stream-1' },
payload: {
status: MothershipStreamV1CompletionStatus.cancelled,
},
}),
],
previewSessions: [],
status: MothershipStreamV1CompletionStatus.cancelled,
},
})
expect(result).toHaveLength(2)
expect(result[1]?.contentBlocks).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: MothershipStreamV1EventType.complete,
status: MothershipStreamV1CompletionStatus.cancelled,
}),
])
)
})
})
@@ -0,0 +1,412 @@
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import {
MothershipStreamV1CompletionStatus,
type MothershipStreamV1ErrorPayload,
MothershipStreamV1EventType,
MothershipStreamV1RunKind,
MothershipStreamV1SessionKind,
MothershipStreamV1SpanLifecycleEvent,
MothershipStreamV1SpanPayloadKind,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
interface StreamSnapshotLike {
events: StreamBatchEvent[]
previewSessions: FilePreviewSession[]
status: string
}
interface BuildEffectiveChatTranscriptParams {
messages: PersistedMessage[]
activeStreamId: string | null
streamSnapshot?: StreamSnapshotLike | null
}
type RawPersistedBlock = Record<string, unknown>
export function getLiveAssistantMessageId(streamId: string): string {
return `live-assistant:${streamId}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined
}
function isTerminalStreamStatus(status: string | null | undefined): boolean {
return (
status === MothershipStreamV1CompletionStatus.complete ||
status === MothershipStreamV1CompletionStatus.error ||
status === MothershipStreamV1CompletionStatus.cancelled
)
}
function buildInlineErrorTag(payload: MothershipStreamV1ErrorPayload): string {
const message =
(typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) ||
(typeof payload.message === 'string' ? payload.message : undefined) ||
(typeof payload.error === 'string' ? payload.error : undefined) ||
'An unexpected error occurred'
const provider = typeof payload.provider === 'string' ? payload.provider : undefined
const code = typeof payload.code === 'string' ? payload.code : undefined
return `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
function resolveToolDisplayTitle(ui: unknown): string | undefined {
if (!isRecord(ui)) return undefined
return typeof ui.title === 'string'
? ui.title
: typeof ui.phaseLabel === 'string'
? ui.phaseLabel
: undefined
}
function appendTextBlock(
blocks: RawPersistedBlock[],
content: string,
options: {
lane?: 'subagent'
}
): void {
if (!content) return
const last = blocks[blocks.length - 1]
if (last?.type === MothershipStreamV1EventType.text && last.lane === options.lane) {
last.content = `${typeof last.content === 'string' ? last.content : ''}${content}`
return
}
blocks.push({
type: MothershipStreamV1EventType.text,
...(options.lane ? { lane: options.lane } : {}),
content,
})
}
function buildLiveAssistantMessage(params: {
streamId: string
events: StreamBatchEvent[]
status: string | null | undefined
}): PersistedMessage | null {
const { streamId, events, status } = params
const blocks: RawPersistedBlock[] = []
const toolIndexById = new Map<string, number>()
const subagentByParentToolCallId = new Map<string, string>()
let activeSubagent: string | undefined
let activeSubagentParentToolCallId: string | undefined
let activeCompactionId: string | undefined
let runningText = ''
let lastContentSource: 'main' | 'subagent' | null = null
let requestId: string | undefined
let lastTimestamp: string | undefined
const resolveScopedSubagent = (
agentId: string | undefined,
parentToolCallId: string | undefined
): string | undefined => {
if (agentId) return agentId
if (parentToolCallId) {
const scoped = subagentByParentToolCallId.get(parentToolCallId)
if (scoped) return scoped
}
return activeSubagent
}
const ensureToolBlock = (input: {
toolCallId: string
toolName: string
calledBy?: string
displayTitle?: string
params?: Record<string, unknown>
result?: { success: boolean; output?: unknown; error?: string }
state?: string
}): RawPersistedBlock => {
const existingIndex = toolIndexById.get(input.toolCallId)
if (existingIndex !== undefined) {
const existing = blocks[existingIndex]
const existingToolCall = asPayloadRecord(existing.toolCall)
existing.toolCall = {
...(existingToolCall ?? {}),
id: input.toolCallId,
name: input.toolName,
state:
input.state ??
(typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'),
...(input.calledBy ? { calledBy: input.calledBy } : {}),
...(input.params ? { params: input.params } : {}),
...(input.result ? { result: input.result } : {}),
...(input.displayTitle
? {
display: {
title: input.displayTitle,
},
}
: existingToolCall?.display
? { display: existingToolCall.display }
: {}),
}
return existing
}
const nextBlock: RawPersistedBlock = {
type: MothershipStreamV1EventType.tool,
phase: MothershipStreamV1ToolPhase.call,
toolCall: {
id: input.toolCallId,
name: input.toolName,
state: input.state ?? 'executing',
...(input.calledBy ? { calledBy: input.calledBy } : {}),
...(input.params ? { params: input.params } : {}),
...(input.result ? { result: input.result } : {}),
...(input.displayTitle
? {
display: {
title: input.displayTitle,
},
}
: {}),
},
}
toolIndexById.set(input.toolCallId, blocks.length)
blocks.push(nextBlock)
return nextBlock
}
for (const entry of events) {
const parsed = entry.event
lastTimestamp = parsed.ts
if (typeof parsed.trace?.requestId === 'string') {
requestId = parsed.trace.requestId
}
const scopedParentToolCallId =
typeof parsed.scope?.parentToolCallId === 'string' ? parsed.scope.parentToolCallId : undefined
const scopedAgentId =
typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined
const scopedSubagent = resolveScopedSubagent(scopedAgentId, scopedParentToolCallId)
switch (parsed.type) {
case MothershipStreamV1EventType.session: {
if (parsed.payload.kind === MothershipStreamV1SessionKind.chat) {
continue
}
if (parsed.payload.kind === MothershipStreamV1SessionKind.start) {
continue
}
if (parsed.payload.kind === MothershipStreamV1SessionKind.trace) {
requestId = parsed.payload.requestId
}
continue
}
case MothershipStreamV1EventType.text: {
const chunk = parsed.payload.text
if (!chunk) {
continue
}
const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main'
const needsBoundaryNewline =
lastContentSource !== null &&
lastContentSource !== contentSource &&
runningText.length > 0 &&
!runningText.endsWith('\n')
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
appendTextBlock(blocks, normalizedChunk, {
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
})
runningText += normalizedChunk
lastContentSource = contentSource
continue
}
case MothershipStreamV1EventType.tool: {
const payload = parsed.payload
const toolCallId = payload.toolCallId
const displayTitle = resolveToolDisplayTitle('ui' in payload ? payload.ui : undefined)
if ('previewPhase' in payload) {
continue
}
if (payload.phase === MothershipStreamV1ToolPhase.args_delta) {
continue
}
if (payload.phase === MothershipStreamV1ToolPhase.result) {
ensureToolBlock({
toolCallId,
toolName: payload.toolName,
calledBy: scopedSubagent,
state: resolveStreamToolOutcome(payload),
result: {
success: payload.success,
...(payload.output !== undefined ? { output: payload.output } : {}),
...(typeof payload.error === 'string' ? { error: payload.error } : {}),
},
})
continue
}
ensureToolBlock({
toolCallId,
toolName: payload.toolName,
calledBy: scopedSubagent,
displayTitle,
params: isRecord(payload.arguments) ? payload.arguments : undefined,
state: typeof payload.status === 'string' ? payload.status : 'executing',
})
continue
}
case MothershipStreamV1EventType.span: {
if (parsed.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) {
continue
}
const spanData = asPayloadRecord(parsed.payload.data)
const parentToolCallId =
scopedParentToolCallId ??
(typeof spanData?.tool_call_id === 'string' ? spanData.tool_call_id : undefined)
const name = typeof parsed.payload.agent === 'string' ? parsed.payload.agent : scopedAgentId
if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
if (parentToolCallId) {
subagentByParentToolCallId.set(parentToolCallId, name)
}
activeSubagent = name
activeSubagentParentToolCallId = parentToolCallId
blocks.push({
type: MothershipStreamV1EventType.span,
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: name,
})
continue
}
if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.end) {
if (spanData?.pending === true) {
continue
}
if (parentToolCallId) {
subagentByParentToolCallId.delete(parentToolCallId)
}
if (
!parentToolCallId ||
parentToolCallId === activeSubagentParentToolCallId ||
name === activeSubagent
) {
activeSubagent = undefined
activeSubagentParentToolCallId = undefined
}
blocks.push({
type: MothershipStreamV1EventType.span,
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.end,
})
}
continue
}
case MothershipStreamV1EventType.run: {
if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_start) {
activeCompactionId = `compaction_${entry.eventId}`
ensureToolBlock({
toolCallId: activeCompactionId,
toolName: 'context_compaction',
displayTitle: 'Compacting context...',
state: 'executing',
})
continue
}
if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_done) {
const compactionId = activeCompactionId ?? `compaction_${entry.eventId}`
activeCompactionId = undefined
ensureToolBlock({
toolCallId: compactionId,
toolName: 'context_compaction',
displayTitle: 'Compacted context',
state: MothershipStreamV1ToolOutcome.success,
})
}
continue
}
case MothershipStreamV1EventType.error: {
const tag = buildInlineErrorTag(parsed.payload)
if (runningText.includes(tag)) {
continue
}
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
const content = `${prefix}${tag}`
appendTextBlock(blocks, content, {
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
})
runningText += content
continue
}
case MothershipStreamV1EventType.complete: {
if (parsed.payload.status === MothershipStreamV1CompletionStatus.cancelled) {
blocks.push({
type: MothershipStreamV1EventType.complete,
status: parsed.payload.status,
})
}
continue
}
case MothershipStreamV1EventType.resource: {
continue
}
default: {
continue
}
}
}
if (blocks.length === 0 && !runningText && isTerminalStreamStatus(status)) {
return null
}
return normalizeMessage({
id: getLiveAssistantMessageId(streamId),
role: 'assistant',
content: runningText,
timestamp: lastTimestamp ?? new Date().toISOString(),
...(requestId ? { requestId } : {}),
...(blocks.length > 0 ? { contentBlocks: blocks } : {}),
})
}
export function buildEffectiveChatTranscript({
messages,
activeStreamId,
streamSnapshot,
}: BuildEffectiveChatTranscriptParams): PersistedMessage[] {
if (!activeStreamId || !streamSnapshot) {
return messages
}
const trailingMessage = messages[messages.length - 1]
if (
!trailingMessage ||
trailingMessage.role !== 'user' ||
trailingMessage.id !== activeStreamId
) {
return messages
}
const liveAssistant = buildLiveAssistantMessage({
streamId: activeStreamId,
events: streamSnapshot.events,
status: streamSnapshot.status,
})
if (!liveAssistant) {
return messages
}
return [...messages, liveAssistant]
}
+4 -1
View File
@@ -52,7 +52,7 @@ const FileAttachmentSchema = z.object({
})
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase', 'folder']),
type: z.enum(['workflow', 'table', 'file', 'knowledgebase', 'folder', 'task', 'log', 'generic']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
@@ -64,6 +64,9 @@ const GENERIC_RESOURCE_TITLE: Record<z.infer<typeof ResourceAttachmentSchema>['t
file: 'File',
knowledgebase: 'Knowledge Base',
folder: 'Folder',
task: 'Task',
log: 'Log',
generic: 'Resource',
}
const ChatContextSchema = z.object({
@@ -0,0 +1,46 @@
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
type TerminalToolOutcome =
| typeof MothershipStreamV1ToolOutcome.success
| typeof MothershipStreamV1ToolOutcome.error
| typeof MothershipStreamV1ToolOutcome.cancelled
| typeof MothershipStreamV1ToolOutcome.skipped
| typeof MothershipStreamV1ToolOutcome.rejected
interface ResolveStreamToolOutcomeParams {
output?: unknown
status?: string
success?: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function resolveStreamToolOutcome({
output,
status,
success,
}: ResolveStreamToolOutcomeParams): TerminalToolOutcome {
const outputRecord = isRecord(output) ? output : undefined
const isCancelled =
outputRecord?.reason === 'user_cancelled' ||
outputRecord?.cancelledByUser === true ||
status === MothershipStreamV1ToolOutcome.cancelled
if (isCancelled) {
return MothershipStreamV1ToolOutcome.cancelled
}
switch (status) {
case MothershipStreamV1ToolOutcome.success:
case MothershipStreamV1ToolOutcome.error:
case MothershipStreamV1ToolOutcome.skipped:
case MothershipStreamV1ToolOutcome.rejected:
return status
default:
return success === true
? MothershipStreamV1ToolOutcome.success
: MothershipStreamV1ToolOutcome.error
}
}