chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,6 @@
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { deriveFilePreviewSession } from './apply-file-preview-phase'
const NOW = '2026-06-08T00:00:00.000Z'
function session(overrides: Partial<FilePreviewSession>): FilePreviewSession {
return {
schemaVersion: 1,
id: 'tool-1',
streamId: 'stream-1',
toolCallId: 'tool-1',
status: 'streaming',
fileName: 'deck.pptx',
previewText: '',
previewVersion: 1,
updatedAt: NOW,
...overrides,
}
}
describe('deriveFilePreviewSession', () => {
it('starts a pending session keyed to the tool call', () => {
const next = deriveFilePreviewSession(
undefined,
{ previewPhase: 'file_preview_start', toolCallId: 'tool-1', toolName: 'workspace_file' },
'stream-1',
NOW
)
expect(next.status).toBe('pending')
expect(next.id).toBe('tool-1')
expect(next.previewVersion).toBe(0)
expect(next.streamId).toBe('stream-1')
})
it('captures target identity (fileId, name, kind, operation)', () => {
const next = deriveFilePreviewSession(
session({ status: 'pending' }),
{
previewPhase: 'file_preview_target',
toolCallId: 'tool-1',
toolName: 'workspace_file',
operation: 'append',
target: { kind: 'file_id', fileId: 'file-9', fileName: 'deck.pptx' },
},
'stream-1',
NOW
)
expect(next.fileId).toBe('file-9')
expect(next.targetKind).toBe('file_id')
expect(next.operation).toBe('append')
expect(next.status).toBe('pending')
})
it('appends delta content and advances the version monotonically when none supplied', () => {
const prev = session({ previewText: 'slide one', previewVersion: 2 })
const next = deriveFilePreviewSession(
prev,
{
previewPhase: 'file_preview_content',
toolCallId: 'tool-1',
toolName: 'workspace_file',
content: ' slide two',
contentMode: 'delta',
fileName: 'deck.pptx',
},
'stream-1',
NOW
)
expect(next.status).toBe('streaming')
expect(next.previewText).toBe('slide one slide two')
expect(next.previewVersion).toBe(3)
})
it('appends delta content and uses the supplied version verbatim', () => {
const prev = session({ previewText: 'slide one', previewVersion: 2 })
const next = deriveFilePreviewSession(
prev,
{
previewPhase: 'file_preview_content',
toolCallId: 'tool-1',
toolName: 'workspace_file',
content: ' slide two',
contentMode: 'delta',
previewVersion: 9,
fileName: 'deck.pptx',
},
'stream-1',
NOW
)
expect(next.previewText).toBe('slide one slide two')
expect(next.previewVersion).toBe(9)
})
it('replaces text on a snapshot and carries forward prior fileId', () => {
const prev = session({ previewText: 'old', fileId: 'file-9', previewVersion: 4 })
const next = deriveFilePreviewSession(
prev,
{
previewPhase: 'file_preview_content',
toolCallId: 'tool-1',
toolName: 'workspace_file',
content: 'fresh snapshot',
contentMode: 'snapshot',
previewVersion: 5,
fileName: 'deck.pptx',
},
undefined,
NOW
)
expect(next.previewText).toBe('fresh snapshot')
expect(next.fileId).toBe('file-9')
expect(next.streamId).toBe('stream-1')
})
it('marks completion with completedAt and a resolved version', () => {
const prev = session({ previewText: 'final', previewVersion: 7, fileId: 'file-9' })
const next = deriveFilePreviewSession(
prev,
{
previewPhase: 'file_preview_complete',
toolCallId: 'tool-1',
toolName: 'workspace_file',
fileId: 'file-9',
},
'stream-1',
NOW
)
expect(next.status).toBe('complete')
expect(next.completedAt).toBe(NOW)
expect(next.previewVersion).toBe(7)
expect(next.fileId).toBe('file-9')
})
})
@@ -0,0 +1,93 @@
import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session'
import type {
FilePreviewSession,
FilePreviewTargetKind,
} from '@/lib/copilot/request/session/file-preview-session-contract'
function toTargetKind(value: string | undefined): FilePreviewTargetKind | undefined {
return value === 'new_file' || value === 'file_id' ? value : undefined
}
/**
* Derives the next {@link FilePreviewSession} from a synthetic preview-phase
* payload and the prior session for that tool call. Pure: identity fields prefer
* the payload then fall back to the previous session, content accumulates by
* delta/snapshot, and `previewVersion` advances monotonically. `now` is injected
* so the result is deterministic; the caller supplies a single timestamp used for
* both `updatedAt` and (on completion) `completedAt`.
*/
export function deriveFilePreviewSession(
prev: FilePreviewSession | undefined,
payload: SyntheticFilePreviewPayload,
streamId: string | undefined,
now: string
): FilePreviewSession {
const id = payload.toolCallId
let targetKind = prev?.targetKind
let fileId = prev?.fileId
let fileName = prev?.fileName ?? ''
let operation = prev?.operation
let edit = prev?.edit
if (payload.previewPhase === 'file_preview_target') {
targetKind = toTargetKind(payload.target.kind) ?? targetKind
fileId = payload.target.fileId ?? fileId
fileName = payload.target.fileName ?? fileName
operation = payload.operation ?? operation
} else if (payload.previewPhase === 'file_preview_content') {
targetKind = toTargetKind(payload.targetKind) ?? targetKind
fileId = payload.fileId ?? fileId
fileName = payload.fileName ?? fileName
operation = payload.operation ?? operation
edit = payload.edit ?? edit
} else if (payload.previewPhase === 'file_preview_edit_meta') {
edit = payload.edit ?? edit
} else if (payload.previewPhase === 'file_preview_complete') {
fileId = payload.fileId ?? fileId
}
const base: FilePreviewSession = {
schemaVersion: 1,
id,
streamId: streamId ?? prev?.streamId ?? '',
toolCallId: id,
status: prev?.status ?? 'pending',
fileName,
...(fileId ? { fileId } : {}),
...(targetKind ? { targetKind } : {}),
...(operation ? { operation } : {}),
...(edit ? { edit } : {}),
previewText: prev?.previewText ?? '',
previewVersion: prev?.previewVersion ?? 0,
updatedAt: now,
...(prev?.completedAt ? { completedAt: prev.completedAt } : {}),
}
switch (payload.previewPhase) {
case 'file_preview_start':
case 'file_preview_target':
case 'file_preview_edit_meta':
return base
case 'file_preview_content': {
const previewText =
payload.contentMode === 'delta'
? (prev?.previewText ?? '') + payload.content
: payload.content
const previewVersion =
typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion)
? payload.previewVersion
: (prev?.previewVersion ?? 0) + 1
return { ...base, status: 'streaming', previewText, previewVersion }
}
case 'file_preview_complete':
return {
...base,
status: 'complete',
previewVersion: payload.previewVersion ?? prev?.previewVersion ?? 0,
completedAt: now,
}
}
}
@@ -0,0 +1,5 @@
export { useFilePreviewController } from './use-file-preview-controller'
export {
hasRenderableFilePreviewContent,
shouldReplaceSession,
} from './use-file-preview-sessions'
@@ -0,0 +1,412 @@
'use client'
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useRef,
} from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase'
import {
buildCompletedPreviewSessions,
type FilePreviewSessionsState,
hasRenderableFilePreviewContent,
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
reduceFilePreviewSessions,
useFilePreviewSessions,
} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
interface FilePreviewControllerDeps {
workspaceId: string
setResources: Dispatch<SetStateAction<MothershipResource[]>>
setActiveResourceId: Dispatch<SetStateAction<string | null>>
activeResourceIdRef: MutableRefObject<string | null>
}
function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
/**
* Owns the file-preview state machine for the mothership chat: the preview-session
* store, the streaming-to-committed resource handoff bookkeeping, and translation
* of synthetic preview-phase events into session updates and resource chrome. The
* viewer renders committed binaries (see `useDocPreviewBinary`); this controller
* decides which resource is active and when a generated file is promoted from the
* synthetic `streaming-file` placeholder to its real workspace file.
*/
export function useFilePreviewController({
workspaceId,
setResources,
setActiveResourceId,
activeResourceIdRef,
}: FilePreviewControllerDeps) {
const queryClient = useQueryClient()
const previewActivationOwnerRef = useRef<Map<string, string | null>>(new Map())
const completedPreviewResourceHandoffRef = useRef<
Map<string, { sessionId: string; suppressActivation: boolean }>
>(new Map())
const rememberPreviewActivationOwner = useCallback(
(session: FilePreviewSession) => {
if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) {
return
}
previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current)
},
[activeResourceIdRef]
)
const shouldAutoActivatePreviewSession = useCallback(
(session: FilePreviewSession) => {
if (!session.fileId) {
return false
}
const currentActiveResourceId = activeResourceIdRef.current
const activationOwnerId = previewActivationOwnerRef.current.get(session.id)
return (
currentActiveResourceId === null ||
currentActiveResourceId === session.fileId ||
currentActiveResourceId === 'streaming-file' ||
currentActiveResourceId === activationOwnerId
)
},
[activeResourceIdRef]
)
const seedCompletedPreviewContentCache = useCallback(
(fileId: string, previewText: string) => {
queryClient.setQueriesData<string>(
{ queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') },
previewText
)
const activeFiles = queryClient.getQueryData<Array<{ id: string; key: string }>>(
workspaceFilesKeys.list(workspaceId, 'active')
)
const fileKey = activeFiles?.find((file) => file.id === fileId)?.key
if (fileKey) {
queryClient.setQueryData(
[...workspaceFilesKeys.content(workspaceId, fileId, 'text'), fileKey],
previewText
)
}
},
[queryClient, workspaceId]
)
const {
previewSession,
previewSessionsById,
activePreviewSessionId,
hydratePreviewSessions,
upsertPreviewSession,
completePreviewSession,
removePreviewSession,
resetPreviewSessions,
} = useFilePreviewSessions()
const previewSessionRef = useRef(previewSession)
previewSessionRef.current = previewSession
const previewSessionsRef = useRef(previewSessionsById)
previewSessionsRef.current = previewSessionsById
const activePreviewSessionIdRef = useRef(activePreviewSessionId)
activePreviewSessionIdRef.current = activePreviewSessionId
const latestPreviewTargetToolCallIdRef = useRef<string | null>(null)
const previewSessionsStateRef = useRef<FilePreviewSessionsState>({
activeSessionId: activePreviewSessionId,
sessions: previewSessionsById,
})
previewSessionsStateRef.current = {
activeSessionId: activePreviewSessionId,
sessions: previewSessionsById,
}
const syncPreviewSessionRefs = useCallback((nextState: FilePreviewSessionsState) => {
previewSessionsStateRef.current = nextState
previewSessionsRef.current = nextState.sessions
activePreviewSessionIdRef.current = nextState.activeSessionId
previewSessionRef.current =
nextState.activeSessionId !== null
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
}, [])
const applyPreviewSessionUpdate = useCallback(
(session: FilePreviewSession, options?: { activate?: boolean }) => {
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
type: 'upsert',
session,
...(options?.activate === false ? { activate: false } : {}),
})
syncPreviewSessionRefs(nextState)
upsertPreviewSession(session, options)
return nextState
},
[syncPreviewSessionRefs, upsertPreviewSession]
)
const applyCompletedPreviewSession = useCallback(
(session: FilePreviewSession) => {
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
type: 'complete',
session,
})
syncPreviewSessionRefs(nextState)
completePreviewSession(session)
return nextState
},
[completePreviewSession, syncPreviewSessionRefs]
)
const reconcileTerminalPreviewSessions = useCallback(() => {
const completedAt = new Date().toISOString()
const completedSessions = buildCompletedPreviewSessions(
previewSessionsStateRef.current.sessions,
completedAt
)
for (const session of completedSessions) {
applyCompletedPreviewSession(session)
}
}, [applyCompletedPreviewSession])
const removePreviewSessionImmediate = useCallback(
(sessionId: string) => {
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
type: 'remove',
sessionId,
})
syncPreviewSessionRefs(nextState)
removePreviewSession(sessionId)
return nextState
},
[removePreviewSession, syncPreviewSessionRefs]
)
const resetEphemeralPreviewState = useCallback(
(options?: { removeStreamingResource?: boolean }) => {
previewActivationOwnerRef.current.clear()
completedPreviewResourceHandoffRef.current.clear()
latestPreviewTargetToolCallIdRef.current = null
syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE)
resetPreviewSessions()
if (options?.removeStreamingResource) {
setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
}
},
[resetPreviewSessions, setResources, syncPreviewSessionRefs]
)
const promoteFileResource = useCallback(
(fileId: string, title: string) => {
setResources((current) => {
const withoutStreaming = current.filter((resource) => resource.id !== 'streaming-file')
if (
withoutStreaming.some((resource) => resource.type === 'file' && resource.id === fileId)
) {
return withoutStreaming
}
return [...withoutStreaming, { type: 'file', id: fileId, title }]
})
},
[setResources]
)
const syncPreviewResourceChrome = useCallback(
(session: FilePreviewSession, options?: { activate?: boolean }) => {
if (session.targetKind === 'new_file') {
setResources((current) => {
const existing = current.find((resource) => resource.id === 'streaming-file')
if (existing) {
return current.map((resource) =>
resource.id === 'streaming-file'
? { ...resource, title: session.fileName || 'Writing file...' }
: resource
)
}
return [
...current,
{ type: 'file', id: 'streaming-file', title: session.fileName || 'Writing file...' },
]
})
setActiveResourceId('streaming-file')
return
}
if (session.fileId && hasRenderableFilePreviewContent(session)) {
promoteFileResource(session.fileId, session.fileName || 'File')
if (options?.activate !== false) {
setActiveResourceId(session.fileId)
}
}
},
[promoteFileResource, setActiveResourceId, setResources]
)
const seedPreviewSessions = useCallback(
(sessions: FilePreviewSession[]) => {
if (sessions.length === 0) {
return
}
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
type: 'hydrate',
sessions,
})
syncPreviewSessionRefs(nextState)
hydratePreviewSessions(sessions)
const active =
nextState.activeSessionId !== null
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (active) {
syncPreviewResourceChrome(active, {
activate: active.targetKind === 'new_file' || shouldAutoActivatePreviewSession(active),
})
}
},
[
hydratePreviewSessions,
shouldAutoActivatePreviewSession,
syncPreviewResourceChrome,
syncPreviewSessionRefs,
]
)
const onPreviewPhase = useCallback(
(payload: SyntheticFilePreviewPayload, streamId: string | undefined) => {
const id = payload.toolCallId
const prevSession = previewSessionsRef.current[id]
const nextSession = deriveFilePreviewSession(
prevSession,
payload,
streamId,
new Date().toISOString()
)
if (payload.previewPhase === 'file_preview_start') {
latestPreviewTargetToolCallIdRef.current = id
applyPreviewSessionUpdate(nextSession)
return
}
if (payload.previewPhase === 'file_preview_target') {
latestPreviewTargetToolCallIdRef.current = id
rememberPreviewActivationOwner(nextSession)
const nextState = applyPreviewSessionUpdate(nextSession)
const activePreview =
nextState.activeSessionId !== null
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (activePreview?.id === nextSession.id) {
syncPreviewResourceChrome(activePreview, {
activate:
activePreview.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(activePreview),
})
}
return
}
if (payload.previewPhase === 'file_preview_edit_meta') {
applyPreviewSessionUpdate(nextSession)
return
}
if (payload.previewPhase === 'file_preview_content') {
applyPreviewSessionUpdate(nextSession)
if (!prevSession || !hasRenderableFilePreviewContent(prevSession)) {
syncPreviewResourceChrome(nextSession, {
activate:
nextSession.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(nextSession),
})
}
return
}
const resultData = asPayloadRecord(payload.output)
const outputData = asPayloadRecord(resultData?.data) ?? resultData
const wasRenderableBeforeComplete =
prevSession !== undefined && hasRenderableFilePreviewContent(prevSession)
const nextState = applyCompletedPreviewSession(nextSession)
const fileId = nextSession.fileId
if (fileId && resultData?.success === true && outputData?.id === fileId) {
const fileName = (outputData.name as string) ?? nextSession.fileName ?? 'File'
promoteFileResource(fileId, fileName)
const completedExt = fileName.includes('.')
? (fileName.split('.').pop()?.toLowerCase() ?? '')
: ''
const isCompiledDocPreview = ['docx', 'pptx', 'pdf', 'xlsx'].includes(completedExt)
const shouldActivateOnComplete =
(isCompiledDocPreview ||
(!wasRenderableBeforeComplete && hasRenderableFilePreviewContent(nextSession))) &&
shouldAutoActivatePreviewSession(nextSession)
if (shouldActivateOnComplete) {
setActiveResourceId(fileId)
}
completedPreviewResourceHandoffRef.current.set(fileId, {
sessionId: nextSession.id,
suppressActivation: !shouldActivateOnComplete,
})
if (hasRenderableFilePreviewContent(nextSession)) {
seedCompletedPreviewContentCache(fileId, nextSession.previewText)
}
invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
} else {
const activePreview =
nextState.activeSessionId !== null
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (activePreview) {
syncPreviewResourceChrome(activePreview, {
activate:
activePreview.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(activePreview),
})
}
}
},
[
applyCompletedPreviewSession,
applyPreviewSessionUpdate,
promoteFileResource,
queryClient,
rememberPreviewActivationOwner,
seedCompletedPreviewContentCache,
setActiveResourceId,
shouldAutoActivatePreviewSession,
syncPreviewResourceChrome,
workspaceId,
]
)
return {
previewSession,
previewSessionRef,
previewSessionsRef,
activePreviewSessionIdRef,
latestPreviewTargetToolCallIdRef,
previewActivationOwnerRef,
completedPreviewResourceHandoffRef,
shouldAutoActivatePreviewSession,
applyPreviewSessionUpdate,
removePreviewSessionImmediate,
reconcileTerminalPreviewSessions,
resetEphemeralPreviewState,
promoteFileResource,
seedPreviewSessions,
onPreviewPhase,
}
}
@@ -0,0 +1,567 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
buildCompletedPreviewSessions,
hasRenderableFilePreviewContent,
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
pickActiveSessionId,
reduceFilePreviewSessions,
shouldReplaceSession,
} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions'
function createSession(
overrides: Partial<FilePreviewSession> & Pick<FilePreviewSession, 'id' | 'toolCallId'>
): FilePreviewSession {
return {
schemaVersion: 1,
id: overrides.id,
streamId: overrides.streamId ?? 'stream-1',
toolCallId: overrides.toolCallId,
status: overrides.status ?? 'streaming',
fileName: overrides.fileName ?? `${overrides.id}.md`,
previewText: overrides.previewText ?? '',
previewVersion: overrides.previewVersion ?? 1,
updatedAt: overrides.updatedAt ?? '2026-04-10T00:00:00.000Z',
...(overrides.fileId ? { fileId: overrides.fileId } : {}),
...(overrides.targetKind ? { targetKind: overrides.targetKind } : {}),
...(overrides.operation ? { operation: overrides.operation } : {}),
...(overrides.edit ? { edit: overrides.edit } : {}),
...(overrides.completedAt ? { completedAt: overrides.completedAt } : {}),
}
}
describe('reduceFilePreviewSessions', () => {
it('does not treat a pending empty preview as renderable content', () => {
expect(
hasRenderableFilePreviewContent(
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'pending',
previewText: '',
previewVersion: 0,
})
)
).toBe(false)
})
it('treats emitted preview snapshots as renderable even when empty', () => {
expect(
hasRenderableFilePreviewContent(
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'streaming',
previewText: '',
previewVersion: 1,
})
)
).toBe(true)
})
it('does not replace a completed session with same-version replayed streaming events', () => {
const completed = createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewText: 'final',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:02.000Z',
completedAt: '2026-04-10T00:00:02.000Z',
})
const replayedStreaming = createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'streaming',
previewText: 'final',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:03.000Z',
})
expect(shouldReplaceSession(completed, replayedStreaming)).toBe(false)
})
it('builds complete sessions for terminal stream reconciliation', () => {
const completedAt = '2026-04-10T00:00:10.000Z'
const nextSessions = buildCompletedPreviewSessions(
{
'preview-1': createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'pending',
previewText: 'draft',
}),
'preview-2': createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'streaming',
previewText: 'partial',
}),
'preview-3': createSession({
id: 'preview-3',
toolCallId: 'preview-3',
status: 'complete',
previewText: 'done',
completedAt: '2026-04-10T00:00:03.000Z',
}),
},
completedAt
)
expect(nextSessions).toHaveLength(2)
expect(nextSessions.map((session) => session.id)).toEqual(['preview-1', 'preview-2'])
expect(nextSessions.every((session) => session.status === 'complete')).toBe(true)
expect(nextSessions.every((session) => session.updatedAt === completedAt)).toBe(true)
expect(nextSessions.every((session) => session.completedAt === completedAt)).toBe(true)
})
it('hydrates the latest active preview session', () => {
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'hydrate',
sessions: [
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 1,
updatedAt: '2026-04-10T00:00:00.000Z',
}),
createSession({
id: 'preview-2',
toolCallId: 'preview-2',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:01.000Z',
previewText: 'latest',
}),
],
})
expect(state.activeSessionId).toBe('preview-2')
expect(state.sessions['preview-2']?.previewText).toBe('latest')
})
it('drops the active session when it completes and promotes the next active session', () => {
const hydratedState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'hydrate',
sessions: [
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 1,
updatedAt: '2026-04-10T00:00:00.000Z',
}),
createSession({
id: 'preview-2',
toolCallId: 'preview-2',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:01.000Z',
}),
],
})
const completedState = reduceFilePreviewSessions(hydratedState, {
type: 'complete',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'complete',
previewVersion: 3,
updatedAt: '2026-04-10T00:00:02.000Z',
completedAt: '2026-04-10T00:00:02.000Z',
}),
})
expect(completedState.activeSessionId).toBe('preview-1')
expect(completedState.sessions['preview-1']?.id).toBe('preview-1')
})
it('lingers on the completed session when it is the only one (no successor)', () => {
const onlyStreaming = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:01.000Z',
previewText: 'final',
}),
})
const completed = reduceFilePreviewSessions(onlyStreaming, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
updatedAt: '2026-04-10T00:00:02.000Z',
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'final',
}),
})
expect(completed.activeSessionId).toBe('preview-1')
expect(completed.sessions['preview-1']?.status).toBe('complete')
})
it('releases the linger when a new non-complete session upserts', () => {
const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
previewText: 'section one',
}),
})
const afterComplete = reduceFilePreviewSessions(lingered, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'section one',
}),
})
// New tool call arrives with content — should switch active to the new session.
const afterNew = reduceFilePreviewSessions(afterComplete, {
type: 'upsert',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'streaming',
previewVersion: 1,
previewText: 'section two',
}),
})
expect(afterNew.activeSessionId).toBe('preview-2')
})
it('holds the linger when an empty pending session arrives (no content yet)', () => {
const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
previewText: 'existing content',
}),
})
const afterComplete = reduceFilePreviewSessions(lingered, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'existing content',
}),
})
const afterEmptyUpsert = reduceFilePreviewSessions(afterComplete, {
type: 'upsert',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'pending',
previewVersion: 0,
previewText: '',
}),
})
expect(afterEmptyUpsert.activeSessionId).toBe('preview-1')
expect(pickActiveSessionId(afterEmptyUpsert.sessions, null)).toBe('preview-2')
const afterContent = reduceFilePreviewSessions(afterEmptyUpsert, {
type: 'upsert',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'streaming',
previewVersion: 1,
previewText: 'new content',
}),
})
expect(afterContent.activeSessionId).toBe('preview-2')
})
it('ignores stale complete events for a newer active session', () => {
const activeState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 3,
updatedAt: '2026-04-10T00:00:03.000Z',
}),
})
const staleCompleteState = reduceFilePreviewSessions(activeState, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:02.000Z',
completedAt: '2026-04-10T00:00:02.000Z',
}),
})
expect(staleCompleteState.activeSessionId).toBe('preview-1')
expect(staleCompleteState.sessions['preview-1']?.status).toBe('streaming')
expect(staleCompleteState.sessions['preview-1']?.previewVersion).toBe(3)
})
it('removes a session and clears activeSessionId when the active session is removed', () => {
const withSession = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 1,
previewText: 'content',
}),
})
const removed = reduceFilePreviewSessions(withSession, {
type: 'remove',
sessionId: 'preview-1',
})
expect(removed.activeSessionId).toBeNull()
expect(removed.sessions['preview-1']).toBeUndefined()
})
it('removes a non-active session without changing activeSessionId', () => {
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 1,
previewText: 'active',
}),
})
state = reduceFilePreviewSessions(state, {
type: 'upsert',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
previewVersion: 1,
previewText: 'inactive',
status: 'complete',
completedAt: '2026-04-10T00:00:01.000Z',
}),
})
const removed = reduceFilePreviewSessions(state, {
type: 'remove',
sessionId: 'preview-2',
})
expect(removed.activeSessionId).toBe('preview-1')
expect(removed.sessions['preview-2']).toBeUndefined()
expect(removed.sessions['preview-1']).toBeDefined()
})
it('removing a non-existent session is a no-op', () => {
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
})
const next = reduceFilePreviewSessions(state, { type: 'remove', sessionId: 'does-not-exist' })
expect(next).toBe(state)
})
it('reset clears all sessions and activeSessionId', () => {
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
})
state = reduceFilePreviewSessions(state, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 2,
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'final',
}),
})
const reset = reduceFilePreviewSessions(state, { type: 'reset' })
expect(reset.activeSessionId).toBeNull()
expect(Object.keys(reset.sessions)).toHaveLength(0)
})
it('hydrate with an empty sessions array is a no-op', () => {
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
})
const next = reduceFilePreviewSessions(state, { type: 'hydrate', sessions: [] })
expect(next).toBe(state)
})
it('hydrate merges incoming sessions into existing state without replacing non-stale sessions', () => {
const existing = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 3,
updatedAt: '2026-04-10T00:00:03.000Z',
previewText: 'current',
}),
})
const hydrated = reduceFilePreviewSessions(existing, {
type: 'hydrate',
sessions: [
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:02.000Z',
previewText: 'stale',
}),
createSession({
id: 'preview-2',
toolCallId: 'preview-2',
previewVersion: 1,
updatedAt: '2026-04-10T00:00:04.000Z',
previewText: 'new',
}),
],
})
expect(hydrated.sessions['preview-1']?.previewVersion).toBe(3)
expect(hydrated.sessions['preview-1']?.previewText).toBe('current')
expect(hydrated.sessions['preview-2']?.previewText).toBe('new')
})
it('hydrate preserves linger when no non-complete session exists in incoming batch', () => {
const lingered = reduceFilePreviewSessions(
reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
previewText: 'final',
}),
}),
{
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'final',
}),
}
)
// Hydrate with the same completed session — no non-complete successor.
const afterHydrate = reduceFilePreviewSessions(lingered, {
type: 'hydrate',
sessions: [
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
previewText: 'final',
completedAt: '2026-04-10T00:00:02.000Z',
}),
],
})
expect(afterHydrate.activeSessionId).toBe('preview-1')
})
it('hydrate releases linger when a non-complete session is present in the incoming batch', () => {
const lingered = reduceFilePreviewSessions(
reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
previewVersion: 2,
previewText: 'final',
}),
}),
{
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 3,
completedAt: '2026-04-10T00:00:02.000Z',
previewText: 'final',
}),
}
)
const afterHydrate = reduceFilePreviewSessions(lingered, {
type: 'hydrate',
sessions: [
createSession({
id: 'preview-2',
toolCallId: 'preview-2',
status: 'streaming',
previewVersion: 1,
previewText: 'new content',
}),
],
})
expect(afterHydrate.activeSessionId).toBe('preview-2')
})
it('complete for a non-active session updates the session but keeps activeSessionId', () => {
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
})
state = reduceFilePreviewSessions(state, {
type: 'upsert',
session: createSession({
id: 'preview-2',
toolCallId: 'preview-2',
previewVersion: 1,
previewText: 'background',
}),
})
const completed = reduceFilePreviewSessions(state, {
type: 'complete',
session: createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewVersion: 2,
completedAt: '2026-04-10T00:00:02.000Z',
}),
})
expect(completed.activeSessionId).toBe('preview-2')
expect(completed.sessions['preview-1']?.status).toBe('complete')
})
})
@@ -0,0 +1,224 @@
import { useCallback, useMemo, useReducer } from 'react'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
export interface FilePreviewSessionsState {
activeSessionId: string | null
sessions: Record<string, FilePreviewSession>
}
export type FilePreviewSessionsAction =
| { type: 'hydrate'; sessions: FilePreviewSession[] }
| { type: 'upsert'; session: FilePreviewSession; activate?: boolean }
| { type: 'complete'; session: FilePreviewSession }
| { type: 'remove'; sessionId: string }
| { type: 'reset' }
export const INITIAL_FILE_PREVIEW_SESSIONS_STATE: FilePreviewSessionsState = {
activeSessionId: null,
sessions: {},
}
export function hasRenderableFilePreviewContent(session: FilePreviewSession): boolean {
return session.previewText.length > 0 || session.previewVersion > 0
}
export function shouldReplaceSession(
current: FilePreviewSession | undefined,
next: FilePreviewSession
): boolean {
if (!current) return true
if (
current.status === 'complete' &&
next.status !== 'complete' &&
next.previewVersion <= current.previewVersion
) {
return false
}
if (next.previewVersion !== current.previewVersion) {
return next.previewVersion > current.previewVersion
}
return next.updatedAt >= current.updatedAt
}
export function pickActiveSessionId(
sessions: Record<string, FilePreviewSession>,
preferredId?: string | null
): string | null {
if (preferredId && sessions[preferredId]?.status !== 'complete') {
return preferredId
}
let latestActive: FilePreviewSession | null = null
for (const session of Object.values(sessions)) {
if (session.status === 'complete') continue
if (!latestActive || shouldReplaceSession(latestActive, session)) {
latestActive = session
}
}
return latestActive?.id ?? null
}
export function buildCompletedPreviewSessions(
sessions: Record<string, FilePreviewSession>,
completedAt: string
): FilePreviewSession[] {
return Object.values(sessions)
.filter((session) => session.status !== 'complete')
.map((session) => ({
...session,
status: 'complete' as const,
updatedAt: completedAt,
completedAt,
}))
}
export function reduceFilePreviewSessions(
state: FilePreviewSessionsState,
action: FilePreviewSessionsAction
): FilePreviewSessionsState {
switch (action.type) {
case 'hydrate': {
if (action.sessions.length === 0) {
return state
}
const nextSessions = { ...state.sessions }
for (const session of action.sessions) {
if (shouldReplaceSession(nextSessions[session.id], session)) {
nextSessions[session.id] = session
}
}
const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
return {
sessions: nextSessions,
activeSessionId: successor ?? state.activeSessionId,
}
}
case 'upsert': {
if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
return state
}
const nextSessions = {
...state.sessions,
[action.session.id]: action.session,
}
let nextActiveSessionId: string | null
if (action.activate === false || action.session.status === 'complete') {
const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
nextActiveSessionId = successor ?? state.activeSessionId
} else {
// Don't switch to a new session until it has renderable content — keeps the viewer mounted.
const currentActive = state.activeSessionId ? nextSessions[state.activeSessionId] : null
const currentHasContent = currentActive
? hasRenderableFilePreviewContent(currentActive)
: false
const incomingHasContent = hasRenderableFilePreviewContent(action.session)
nextActiveSessionId =
currentHasContent && !incomingHasContent ? state.activeSessionId : action.session.id
}
return { sessions: nextSessions, activeSessionId: nextActiveSessionId }
}
case 'complete': {
if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
return state
}
const nextSessions = {
...state.sessions,
[action.session.id]: action.session,
}
if (state.activeSessionId !== action.session.id) {
return { sessions: nextSessions, activeSessionId: state.activeSessionId }
}
const successor = pickActiveSessionId(nextSessions, null)
return {
sessions: nextSessions,
// Linger on this session until a successor upserts. Without it, streamingContent
// becomes undefined between tool calls, collapsing the viewer and clipping scrollTop.
activeSessionId: successor ?? action.session.id,
}
}
case 'remove': {
if (!state.sessions[action.sessionId]) {
return state
}
const nextSessions = { ...state.sessions }
delete nextSessions[action.sessionId]
return {
sessions: nextSessions,
activeSessionId:
state.activeSessionId === action.sessionId
? pickActiveSessionId(nextSessions, null)
: state.activeSessionId,
}
}
case 'reset':
return INITIAL_FILE_PREVIEW_SESSIONS_STATE
default:
return state
}
}
export function useFilePreviewSessions() {
const [state, dispatch] = useReducer(
reduceFilePreviewSessions,
INITIAL_FILE_PREVIEW_SESSIONS_STATE
)
const previewSession = useMemo(
() => (state.activeSessionId ? (state.sessions[state.activeSessionId] ?? null) : null),
[state.activeSessionId, state.sessions]
)
const hydratePreviewSessions = useCallback((sessions: FilePreviewSession[]) => {
dispatch({ type: 'hydrate', sessions })
}, [])
const upsertPreviewSession = useCallback(
(session: FilePreviewSession, options?: { activate?: boolean }) => {
dispatch({
type: 'upsert',
session,
...(options?.activate === false ? { activate: false } : {}),
})
},
[]
)
const completePreviewSession = useCallback((session: FilePreviewSession) => {
dispatch({ type: 'complete', session })
}, [])
const removePreviewSession = useCallback((sessionId: string) => {
dispatch({ type: 'remove', sessionId })
}, [])
const resetPreviewSessions = useCallback(() => {
dispatch({ type: 'reset' })
}, [])
return {
previewSession,
previewSessionsById: state.sessions,
activePreviewSessionId: state.activeSessionId,
hydratePreviewSessions,
upsertPreviewSession,
completePreviewSession,
removePreviewSession,
resetPreviewSessions,
}
}
@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from './stream-context'
const handlers = vi.hoisted(() => ({
handleSessionEvent: vi.fn(),
handleTextEvent: vi.fn(),
handleToolEvent: vi.fn(),
handleResourceEvent: vi.fn(),
handleRunEvent: vi.fn(),
handleSpanEvent: vi.fn(),
handleErrorEvent: vi.fn(),
handleCompleteEvent: vi.fn(),
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event', () => ({
handleSessionEvent: handlers.handleSessionEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event', () => ({
handleTextEvent: handlers.handleTextEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event', () => ({
handleToolEvent: handlers.handleToolEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event', () => ({
handleResourceEvent: handlers.handleResourceEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event', () => ({
handleRunEvent: handlers.handleRunEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event', () => ({
handleSpanEvent: handlers.handleSpanEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event', () => ({
handleErrorEvent: handlers.handleErrorEvent,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event', () => ({
handleCompleteEvent: handlers.handleCompleteEvent,
}))
import { dispatchStreamEvent } from './dispatch-stream-event'
import { createTurnModel } from './turn-model'
function makeCtx(): StreamLoopContext {
return {
state: { model: createTurnModel() } as StreamLoopContext['state'],
deps: {} as StreamLoopContext['deps'],
ops: {} as unknown as StreamLoopContext['ops'],
}
}
function event(type: string, scope?: Record<string, unknown>): PersistedStreamEventEnvelope {
return {
type,
payload: {},
scope,
seq: 1,
stream: { streamId: 's' },
ts: '2026-01-01T00:00:00Z',
v: 1,
} as unknown as PersistedStreamEventEnvelope
}
describe('dispatchStreamEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('routes every event type to exactly its handler', () => {
const ctx = makeCtx()
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.session))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.text))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.tool))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.resource))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.run))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.span))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.error))
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.complete))
expect(handlers.handleSessionEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleTextEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleToolEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleResourceEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleRunEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleSpanEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleErrorEvent).toHaveBeenCalledTimes(1)
expect(handlers.handleCompleteEvent).toHaveBeenCalledTimes(1)
})
it('computes and passes per-event scope to the span handler', () => {
const ctx = makeCtx()
dispatchStreamEvent(
ctx,
event(MothershipStreamV1EventType.span, { spanId: 'span-9', agentId: 'agent-x' })
)
const call = handlers.handleSpanEvent.mock.calls[0]
expect(call[0]).toBe(ctx)
const scope = call[2]
expect(scope.scopedSpanId).toBe('span-9')
expect(scope.scopedAgentId).toBe('agent-x')
})
it('invokes ctx-only handlers (session/run/complete) without a scope argument', () => {
const ctx = makeCtx()
const sessionEvent = event(MothershipStreamV1EventType.session)
dispatchStreamEvent(ctx, sessionEvent)
expect(handlers.handleSessionEvent).toHaveBeenCalledWith(ctx, sessionEvent)
expect(handlers.handleSessionEvent.mock.calls[0]).toHaveLength(2)
})
it('ignores unknown event types without throwing', () => {
const ctx = makeCtx()
expect(() => dispatchStreamEvent(ctx, event('totally-unknown'))).not.toThrow()
expect(handlers.handleSessionEvent).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,71 @@
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import { handleCompleteEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event'
import { handleErrorEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event'
import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event'
import { handleRunEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event'
import { handleSessionEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event'
import { handleSpanEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event'
import { handleTextEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event'
import { handleToolEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event'
import type {
StreamEventScope,
StreamLoopContext,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import { reduceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
// The model owns subagent attribution by scope identity; only the span handler
// needs scope, and only these three fields (agent id for file-preview seeding,
// span/parent ids for lane identity).
function computeEventScope(parsed: PersistedStreamEventEnvelope): StreamEventScope {
return {
scopedParentToolCallId:
typeof parsed.scope?.parentToolCallId === 'string'
? parsed.scope.parentToolCallId
: undefined,
scopedAgentId: typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined,
scopedSpanId: typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined,
}
}
/**
* Folds a parsed stream event into the model (the single source of truth), then
* routes it to its side-effect handler. Span scope is computed only for the span
* handler (handlers no longer nest blocks — the model does). The caller's
* transport loop owns staleness, cursor dedup, and `streamId`/`streamRequestId`.
*/
export function dispatchStreamEvent(
ctx: StreamLoopContext,
parsed: PersistedStreamEventEnvelope
): void {
// The model is the single source of truth: fold every event into it first,
// then run the handlers for their side effects (resource/query/preview) and
// the snapshot flush, which serializes the model.
reduceEvent(ctx.state.model, parsed)
switch (parsed.type) {
case MothershipStreamV1EventType.session:
handleSessionEvent(ctx, parsed)
break
case MothershipStreamV1EventType.text:
handleTextEvent(ctx, parsed)
break
case MothershipStreamV1EventType.tool:
handleToolEvent(ctx, parsed)
break
case MothershipStreamV1EventType.resource:
handleResourceEvent(ctx, parsed)
break
case MothershipStreamV1EventType.run:
handleRunEvent(ctx, parsed)
break
case MothershipStreamV1EventType.span:
handleSpanEvent(ctx, parsed, computeEventScope(parsed))
break
case MothershipStreamV1EventType.error:
handleErrorEvent(ctx, parsed)
break
case MothershipStreamV1EventType.complete:
handleCompleteEvent(ctx, parsed)
break
}
}
@@ -0,0 +1,14 @@
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
type CompleteEvent = Extract<PersistedStreamEventEnvelope, { type: 'complete' }>
/**
* Turn termination and the deterministic propagation of the outcome to any
* still-open node are folded into the model by `reduceEvent` (which skips an
* async pause). This handler only records the terminal flag and flushes.
*/
export function handleCompleteEvent(ctx: StreamLoopContext, _parsed: CompleteEvent): void {
ctx.state.sawCompleteEvent = true
ctx.ops.flush()
}
@@ -0,0 +1,16 @@
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
type ErrorEvent = Extract<PersistedStreamEventEnvelope, { type: 'error' }>
/**
* The inline error tag is folded into the model by `reduceEvent` (scoped to the
* erroring lane). This handler owns the side effects: flag the stream error and
* surface the message, then flush the serialized snapshot.
*/
export function handleErrorEvent(ctx: StreamLoopContext, parsed: ErrorEvent): void {
const { state, ops, deps } = ctx
state.sawStreamError = true
deps.setError(parsed.payload.message || parsed.payload.error || 'An error occurred')
ops.flush()
}
@@ -0,0 +1,134 @@
import {
type MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
hasRenderableFilePreviewContent,
shouldReplaceSession,
} from '@/app/workspace/[workspaceId]/home/hooks/preview'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
type ResourceEvent = Extract<
PersistedStreamEventEnvelope,
{ type: typeof MothershipStreamV1EventType.resource }
>
/**
* Applies a streamed resource upsert/remove to the mothership resource list,
* reconciling it with any in-flight or just-completed file-preview handoff so a
* generated file is not activated out from under the user while its preview is
* still streaming. Workflow resources are mirrored into the workflow registry.
*/
export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEvent): void {
const {
workspaceId,
queryClient,
addResource,
removeResource,
setResources,
setActiveResourceId,
resourcesRef,
activeResourceIdRef,
previewSessionsRef,
completedPreviewResourceHandoffRef,
previewActivationOwnerRef,
shouldAutoActivatePreviewSession,
ensureWorkflowInRegistry,
onResourceEventRef,
} = ctx.deps
const onResourceEvent = onResourceEventRef.current
const payload = parsed.payload
const resource = payload.resource
if (payload.op === MothershipStreamV1ResourceOp.remove) {
removeResource(resource.type as MothershipResourceType, resource.id)
invalidateResourceQueries(
queryClient,
workspaceId,
resource.type as MothershipResourceType,
resource.id
)
onResourceEvent?.()
return
}
const nextResource = {
type: resource.type as MothershipResourceType,
id: resource.id,
title: typeof resource.title === 'string' ? resource.title : resource.id,
}
const completedPreviewHandoff =
nextResource.type === 'file'
? completedPreviewResourceHandoffRef.current.get(nextResource.id)
: undefined
const matchingPreviewSessions =
nextResource.type === 'file'
? Object.values(previewSessionsRef.current).filter(
(session) => session.fileId === nextResource.id
)
: []
const latestPreviewForResource = (
sessions: FilePreviewSession[]
): FilePreviewSession | undefined =>
sessions.reduce<FilePreviewSession | undefined>(
(latest, session) => (shouldReplaceSession(latest, session) ? session : latest),
undefined
)
const latestActivePreviewForResource = latestPreviewForResource(
matchingPreviewSessions.filter((session) => session.status !== 'complete')
)
const previewForResource =
latestActivePreviewForResource ?? latestPreviewForResource(matchingPreviewSessions)
const isCompletedPreviewHandoffCurrent =
completedPreviewHandoff !== undefined &&
(!latestActivePreviewForResource ||
latestActivePreviewForResource.id === completedPreviewHandoff.sessionId)
if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
}
const shouldSuppressFileResourceActivation =
(isCompletedPreviewHandoffCurrent && completedPreviewHandoff?.suppressActivation === true) ||
(previewForResource !== undefined &&
previewForResource.status !== 'complete' &&
(!hasRenderableFilePreviewContent(previewForResource) ||
!shouldAutoActivatePreviewSession(previewForResource)))
const wasAdded = shouldSuppressFileResourceActivation
? !resourcesRef.current.some((r) => r.type === nextResource.type && r.id === nextResource.id)
: addResource(nextResource)
if (shouldSuppressFileResourceActivation && wasAdded) {
setResources((current) =>
current.some((r) => r.type === nextResource.type && r.id === nextResource.id)
? current
: [...current, nextResource]
)
}
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
}
invalidateResourceQueries(queryClient, workspaceId, nextResource.type, nextResource.id)
if (
!shouldSuppressFileResourceActivation &&
!wasAdded &&
activeResourceIdRef.current !== nextResource.id
) {
setActiveResourceId(nextResource.id)
}
onResourceEvent?.()
if (nextResource.type === 'workflow') {
const wasRegistered = ensureWorkflowInRegistry(nextResource.id, nextResource.title, workspaceId)
if (wasAdded && wasRegistered) {
useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id)
} else {
useWorkflowRegistry.getState().loadWorkflowState(nextResource.id)
}
}
}
@@ -0,0 +1,13 @@
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
type RunEvent = Extract<PersistedStreamEventEnvelope, { type: 'run' }>
/**
* Compaction lifecycle is folded into the model by `reduceEvent` (it opens and
* closes a `context_compaction` node with titles). This handler only flushes the
* serialized snapshot.
*/
export function handleRunEvent(ctx: StreamLoopContext, _parsed: RunEvent): void {
ctx.ops.flush()
}
@@ -0,0 +1,69 @@
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
type SessionEvent = Extract<PersistedStreamEventEnvelope, { type: 'session' }>
export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent): void {
const { deps } = ctx
const payload = parsed.payload
const payloadChatId =
payload.kind === MothershipStreamV1SessionKind.chat
? payload.chatId
: typeof parsed.stream?.chatId === 'string'
? parsed.stream.chatId
: undefined
if (payload.kind === MothershipStreamV1SessionKind.chat && payloadChatId) {
const isNewChat = !deps.chatIdRef.current
deps.chatIdRef.current = payloadChatId
const selected = deps.selectedChatIdRef.current
if (selected == null) {
if (isNewChat) {
deps.setResolvedChatId(payloadChatId)
}
} else if (payloadChatId === selected) {
deps.setResolvedChatId(payloadChatId)
}
deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
if (isNewChat) {
const userMsg = deps.pendingUserMsgRef.current
const activeStreamId = deps.streamIdRef.current
if (userMsg && activeStreamId) {
const assistantMessage = deps.buildAssistantSnapshotMessage({
id:
deps.activeTurnRef.current?.assistantMessageId ??
getLiveAssistantMessageId(activeStreamId),
content: deps.streamingContentRef.current,
contentBlocks: deps.streamingBlocksRef.current,
})
const seededMessages = [userMsg, assistantMessage]
deps.queryClient.setQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(payloadChatId),
{
id: payloadChatId,
title: null,
messages: seededMessages,
activeStreamId,
resources: deps.resourcesRef.current,
}
)
}
deps.setPendingMessages([])
if (!deps.workflowIdRef.current) {
window.history.replaceState(
null,
'',
`/workspace/${deps.workspaceId}/chat/${payloadChatId}`
)
}
}
}
if (payload.kind === MothershipStreamV1SessionKind.title) {
deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
deps.onTitleUpdateRef.current?.()
}
}
@@ -0,0 +1,86 @@
import {
MothershipStreamV1SpanLifecycleEvent,
MothershipStreamV1SpanPayloadKind,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type {
StreamEventScope,
StreamLoopContext,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import {
asPayloadRecord,
FILE_SUBAGENT_ID,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers'
type SpanEvent = Extract<PersistedStreamEventEnvelope, { type: 'span' }>
/**
* Side effects for subagent span lifecycle. The model owns the subagent
* group/nesting/close (via `reduceEvent`); this handler only seeds the file
* preview session on a fresh file-subagent start and reconciles the file
* resource chrome on end, then flushes the model-derived snapshot.
*/
export function handleSpanEvent(
ctx: StreamLoopContext,
parsed: SpanEvent,
scope: StreamEventScope
): void {
const { state, ops, deps } = ctx
const { scopedParentToolCallId, scopedAgentId, scopedSpanId } = scope
const payload = parsed.payload
if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) {
return
}
const spanData = asPayloadRecord(payload.data)
const parentToolCallIdFromData =
typeof spanData?.tool_call_id === 'string'
? spanData.tool_call_id
: typeof spanData?.toolCallId === 'string'
? spanData.toolCallId
: undefined
const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData
const isPendingPause = spanData?.pending === true
const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId
if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name === FILE_SUBAGENT_ID) {
// Seed the pending preview session only on a freshly-opened lane (the agent
// node was created by this event), so concurrent file subagents don't re-seed.
const node = scopedSpanId ? state.model.nodes.get(scopedSpanId) : undefined
const isNewLane = node?.kind === 'agent' && node.seq === parsed.seq
if (isNewLane) {
deps.applyPreviewSessionUpdate({
schemaVersion: 1,
id: parentToolCallId || 'file-preview',
streamId: deps.streamIdRef.current ?? '',
toolCallId: parentToolCallId || 'file-preview',
status: 'pending',
fileName: '',
previewText: '',
previewVersion: 0,
updatedAt: new Date().toISOString(),
})
}
ops.flush()
return
}
if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) {
if (isPendingPause) {
return
}
if (
deps.previewSessionRef.current &&
(!deps.activePreviewSessionIdRef.current ||
deps.previewSessionRef.current.status === 'complete')
) {
const lastFileResource = deps.resourcesRef.current.find(
(r) => r.type === 'file' && r.id !== 'streaming-file'
)
deps.setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
if (lastFileResource) {
deps.setActiveResourceId(lastFileResource.id)
}
}
ops.flush()
}
}
@@ -0,0 +1,13 @@
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
type TextEvent = Extract<PersistedStreamEventEnvelope, { type: 'text' }>
/**
* Text content is folded into the model by `reduceEvent` (main and subagent
* lanes are kept distinct, so there is no manual boundary-newline). This handler
* only schedules a paced flush of the serialized snapshot.
*/
export function handleTextEvent(ctx: StreamLoopContext, _parsed: TextEvent): void {
ctx.ops.flushText()
}
@@ -0,0 +1,126 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/copilot/resources/extraction', () => ({
isResourceToolName: vi.fn(() => false),
extractResourcesFromToolResult: vi.fn(() => []),
}))
vi.mock('@/lib/copilot/tools/workflow-tools', () => ({
isWorkflowToolName: vi.fn(() => false),
}))
vi.mock(
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry',
() => ({ invalidateResourceQueries: vi.fn() })
)
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import { dispatchStreamEvent } from './dispatch-stream-event'
import { createStreamLoopContext, type StreamLoopContext } from './stream-context'
import { makeStreamLoopDeps, ref } from './stream-test-helpers'
import type { ToolNode } from './turn-model'
let seq = 0
function toolEnv(payload: Record<string, unknown>): PersistedStreamEventEnvelope {
return {
type: 'tool',
v: 1,
seq: ++seq,
ts: '',
stream: { streamId: 's', cursor: String(seq) },
payload,
} as unknown as PersistedStreamEventEnvelope
}
const toolCall = (id: string, name = 'my_tool') =>
toolEnv({ phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name })
const toolResult = (id: string, success: boolean, name = 'my_tool') =>
toolEnv({
phase: 'result',
executor: 'go',
mode: 'sync',
toolCallId: id,
toolName: name,
success,
status: success ? 'success' : 'error',
})
const workspaceFileCall = (id: string) =>
toolEnv({
phase: 'call',
executor: 'sim',
mode: 'async',
toolCallId: id,
toolName: 'workspace_file',
arguments: { operation: 'append', target: { kind: 'file_id', fileId: 'f1' } },
})
const filePreviewComplete = (id: string) =>
toolEnv({ previewPhase: 'file_preview_complete', toolCallId: id, toolName: 'workspace_file' })
function streamingSession(toolCallId: string): FilePreviewSession {
return {
schemaVersion: 1,
id: toolCallId,
streamId: 's',
toolCallId,
status: 'streaming',
fileName: 'doc.md',
previewText: 'hello',
previewVersion: 1,
updatedAt: '',
}
}
function toolNode(ctx: StreamLoopContext, id: string): ToolNode {
const node = ctx.state.model.nodes.get(id)
expect(node?.kind).toBe('tool')
return node as ToolNode
}
describe('tool events (dispatch → model + side effects)', () => {
it('runs a tool then settles success, firing the onToolResult side effect', () => {
const onToolResult = vi.fn()
const ctx = createStreamLoopContext(makeStreamLoopDeps({ onToolResultRef: ref(onToolResult) }))
dispatchStreamEvent(ctx, toolCall('tc-1'))
expect(toolNode(ctx, 'tc-1').status).toBe('running')
dispatchStreamEvent(ctx, toolResult('tc-1', true))
expect(toolNode(ctx, 'tc-1').status).toBe('success')
expect(onToolResult).toHaveBeenCalledWith('my_tool', true, undefined)
})
it('buffers a result that arrives before its call, then applies it', () => {
const ctx = createStreamLoopContext(makeStreamLoopDeps())
dispatchStreamEvent(ctx, toolResult('tc-2', true))
expect(ctx.state.model.nodes.has('tc-2')).toBe(false)
dispatchStreamEvent(ctx, toolCall('tc-2'))
expect(toolNode(ctx, 'tc-2').status).toBe('success')
})
it('marks an unsuccessful result as error', () => {
const ctx = createStreamLoopContext(makeStreamLoopDeps())
dispatchStreamEvent(ctx, toolCall('tc-3'))
dispatchStreamEvent(ctx, toolResult('tc-3', false))
expect(toolNode(ctx, 'tc-3').status).toBe('error')
})
it('settles a file-write row on its own result, independent of a streaming preview session', () => {
const previewSessionsRef = ref<Record<string, FilePreviewSession>>({})
const ctx = createStreamLoopContext(makeStreamLoopDeps({ previewSessionsRef }))
dispatchStreamEvent(ctx, workspaceFileCall('wf-1'))
expect(toolNode(ctx, 'wf-1').status).toBe('running')
previewSessionsRef.current['wf-1'] = streamingSession('wf-1')
dispatchStreamEvent(ctx, toolResult('wf-1', true, 'workspace_file'))
expect(toolNode(ctx, 'wf-1').status).toBe('success')
// A later file_preview_complete is a preview-only signal; the tool row stays settled.
dispatchStreamEvent(ctx, filePreviewComplete('wf-1'))
expect(toolNode(ctx, 'wf-1').status).toBe('success')
})
})
@@ -0,0 +1,191 @@
import {
MothershipStreamV1ToolPhase,
MothershipStreamV1ToolStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
extractResourcesFromToolResult,
isResourceToolName,
} from '@/lib/copilot/resources/extraction'
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import {
DEPLOY_TOOL_NAMES,
extractResourceFromReadResult,
FILE_SUBAGENT_ID,
FOLDER_TOOL_NAMES,
WORKFLOW_MUTATION_TOOL_NAMES,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers'
import {
MAIN_SPAN,
resolveToolId,
type ToolNode,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { workflowKeys } from '@/hooks/queries/workflows'
type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }>
/** The display agent id for a tool's owning span (undefined on the main lane). */
function agentIdForSpan(ctx: StreamLoopContext, spanId: string): string | undefined {
if (spanId === MAIN_SPAN) return undefined
const agent = ctx.state.model.nodes.get(spanId)
return agent?.kind === 'agent' ? agent.agentId : undefined
}
/**
* Runs the external side effects of a finished tool (resource extraction, query
* invalidation, file-resource promotion, preview cleanup, onToolResult). The
* tool's lifecycle/status is owned by the model; this reads the settled node and
* only performs side effects, so the model stays the single source of state.
*/
function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void {
const { deps } = ctx
const name = node.name
const output = node.result?.output
const isSuccess = node.status === 'success'
const params = node.args
const calledBy = agentIdForSpan(ctx, node.spanId)
if (name === ReadTool.id && isSuccess) {
const resource = extractResourceFromReadResult(
typeof params?.path === 'string' ? params.path : undefined,
output
)
if (resource && deps.addResource(resource)) {
deps.onResourceEventRef.current?.()
}
}
if (DEPLOY_TOOL_NAMES.has(name) && isSuccess) {
const out = output as Record<string, unknown> | undefined
const deployedWorkflowId = (out?.workflowId as string) ?? undefined
if (deployedWorkflowId && typeof out?.isDeployed === 'boolean') {
deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.info(deployedWorkflowId) })
deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(deployedWorkflowId) })
deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) })
}
}
if (FOLDER_TOOL_NAMES.has(name) && isSuccess) {
deps.queryClient.invalidateQueries({ queryKey: folderKeys.list(deps.workspaceId) })
}
if (WORKFLOW_MUTATION_TOOL_NAMES.has(name) && isSuccess) {
deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) })
}
const extractedResources =
isSuccess && isResourceToolName(name)
? extractResourcesFromToolResult(name, params, output)
: []
for (const resource of extractedResources) {
invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id)
}
if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) {
const out = output as Record<string, unknown> | undefined
const editData =
out && typeof out.data === 'object' && out.data !== null
? (out.data as Record<string, unknown>)
: undefined
const editedFileId =
(typeof editData?.id === 'string' ? editData.id : undefined) ??
deps.previewSessionRef.current?.fileId
if (editedFileId) {
const editedFileName =
(typeof editData?.name === 'string' ? editData.name : undefined) ??
deps.previewSessionRef.current?.fileName ??
'File'
deps.promoteFileResource(editedFileId, editedFileName)
if (
deps.activeResourceIdRef.current === null ||
deps.activeResourceIdRef.current === 'streaming-file' ||
deps.activeResourceIdRef.current === editedFileId
) {
deps.setActiveResourceId(editedFileId)
}
invalidateResourceQueries(deps.queryClient, deps.workspaceId, 'file', editedFileId)
}
}
deps.onToolResultRef.current?.(name, isSuccess, output)
const workspaceFileOperation =
name === WorkspaceFile.id && typeof params?.operation === 'string'
? params.operation
: undefined
const shouldKeepWorkspacePreviewOpen =
name === WorkspaceFile.id &&
(workspaceFileOperation === 'append' ||
workspaceFileOperation === 'update' ||
workspaceFileOperation === 'patch')
if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) {
if (name === WorkspaceFile.id) {
deps.removePreviewSessionImmediate(node.id)
}
const fileResource = extractedResources.find((r) => r.type === 'file')
if (fileResource) {
deps.promoteFileResource(fileResource.id, fileResource.title)
deps.setActiveResourceId(fileResource.id)
invalidateResourceQueries(deps.queryClient, deps.workspaceId, 'file', fileResource.id)
} else if (calledBy !== FILE_SUBAGENT_ID) {
deps.setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
}
}
}
/**
* Side effects for tool events. State (the tool node, its status, args, and the
* edit_content row merge) is owned by `reduceEvent`; this handler routes preview
* phases, fires client workflow tools, and runs result side effects, then
* flushes the model-derived snapshot.
*/
export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void {
const { state, ops, deps } = ctx
const payload = parsed.payload
const rawId = payload.toolCallId
if ('previewPhase' in payload) {
// The file preview panel is a separate concern: forward the phase to the
// preview controller, never coupling it to tool-row status.
deps.onPreviewPhase(payload, parsed.stream?.streamId)
return
}
if (payload.phase === MothershipStreamV1ToolPhase.args_delta) {
ops.flushText()
return
}
const node = state.model.nodes.get(resolveToolId(state.model, rawId))
if (payload.phase === MothershipStreamV1ToolPhase.result) {
if (node?.kind === 'tool' && node.result) runToolResultSideEffects(ctx, node)
ops.flush()
return
}
// Call phase. If a buffered result-before-call was applied to this node by the
// reducer, run its side effects now (the result event had no node to act on).
if (node?.kind === 'tool' && node.result) runToolResultSideEffects(ctx, node)
const name = payload.toolName
const isPartial =
payload.partial === true || payload.status === MothershipStreamV1ToolStatus.generating
if (isWorkflowToolName(name) && !isPartial) {
const shouldStartWorkflowTool =
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
node?.kind === 'tool' &&
node.status === 'running' &&
!node.result
if (shouldStartWorkflowTool) {
const args = payload.arguments as Record<string, unknown> | undefined
deps.startClientWorkflowTool(rawId, name, args ?? {})
}
}
ops.flush()
}
@@ -0,0 +1,12 @@
export { dispatchStreamEvent } from './dispatch-stream-event'
export {
type ActiveTurn,
createStreamLoopContext,
type StreamEventScope,
type StreamLoopContext,
type StreamLoopDeps,
type StreamLoopOptions,
type StreamLoopState,
} from './stream-context'
export { finalizeResidualToolCalls } from './stream-helpers'
export { applyTurnTerminal } from './turn-model'
@@ -0,0 +1,205 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
import { createStreamLoopContext } from './stream-context'
import { makeStreamLoopDeps, ref } from './stream-test-helpers'
import { reduceEvent } from './turn-model'
function textEnvelope(text: string): PersistedStreamEventEnvelope {
return {
v: 1,
seq: 1,
ts: '',
stream: { streamId: 's', cursor: '1' },
type: 'text',
payload: { channel: 'assistant', text },
} as unknown as PersistedStreamEventEnvelope
}
describe('createStreamLoopContext', () => {
describe('isStale', () => {
it('is stale when the generation no longer matches expectedGen', () => {
const ctx = createStreamLoopContext(
makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2) })
)
expect(ctx.ops.isStale()).toBe(true)
})
it('is stale when shouldContinue returns false', () => {
const ctx = createStreamLoopContext(
makeStreamLoopDeps({
expectedGen: 1,
streamGenRef: ref(1),
options: { shouldContinue: () => false },
})
)
expect(ctx.ops.isStale()).toBe(true)
})
it('is not stale when the generation matches and shouldContinue is true', () => {
const ctx = createStreamLoopContext(
makeStreamLoopDeps({
expectedGen: 1,
streamGenRef: ref(1),
options: { shouldContinue: () => true },
})
)
expect(ctx.ops.isStale()).toBe(false)
})
it('is not stale when expectedGen is undefined (no generation guard)', () => {
const ctx = createStreamLoopContext(
makeStreamLoopDeps({ expectedGen: undefined, streamGenRef: ref(5) })
)
expect(ctx.ops.isStale()).toBe(false)
})
})
describe('fresh (non-preserve) initialization', () => {
it('resets the shared streaming refs when the stream is live', () => {
const streamingContentRef = ref('leftover')
const streamingBlocksRef = ref<ContentBlock[]>([{ type: 'text', content: 'old' }])
createStreamLoopContext(
makeStreamLoopDeps({
expectedGen: 1,
streamGenRef: ref(1),
streamingContentRef,
streamingBlocksRef,
})
)
expect(streamingContentRef.current).toBe('')
expect(streamingBlocksRef.current).toEqual([])
})
it('does NOT reset the shared refs when already stale (parity with the original ordering)', () => {
const streamingContentRef = ref('live-content')
const streamingBlocksRef = ref<ContentBlock[]>([{ type: 'text', content: 'live' }])
// expectedGen !== streamGen => stale at construction time
createStreamLoopContext(
makeStreamLoopDeps({
expectedGen: 1,
streamGenRef: ref(2),
streamingContentRef,
streamingBlocksRef,
})
)
expect(streamingContentRef.current).toBe('live-content')
expect(streamingBlocksRef.current).toEqual([{ type: 'text', content: 'live' }])
})
})
describe('preserveExistingState reconnect hydration', () => {
it('rebuilds the model (tools and subagent lanes) from the persisted snapshot', () => {
const blocks: ContentBlock[] = [
{ type: 'text', content: 'hi' },
{
type: 'tool_call',
toolCall: {
id: 'tc-1',
name: 'read',
status: ToolCallStatus.success,
params: { path: '/a' },
},
},
{ type: 'subagent', content: 'file', spanId: 'span-1', parentToolCallId: 'tc-1' },
]
const ctx = createStreamLoopContext(
makeStreamLoopDeps({
options: { preserveExistingState: true },
streamingBlocksRef: ref<ContentBlock[]>(blocks),
streamingContentRef: ref('hi'),
})
)
const tool = ctx.state.model.nodes.get('tc-1')
expect(tool?.kind).toBe('tool')
expect((tool as { status: string }).status).toBe('success')
const agent = ctx.state.model.nodes.get('span-1')
expect(agent?.kind).toBe('agent')
expect((agent as { agentId: string }).agentId).toBe('file')
})
it('rebuilds a closed subagent lane as terminal at a subagent_end marker', () => {
const blocks: ContentBlock[] = [
{ type: 'subagent', content: 'file', spanId: 'span-1' },
{ type: 'subagent_end', spanId: 'span-1' },
]
const ctx = createStreamLoopContext(
makeStreamLoopDeps({
options: { preserveExistingState: true },
streamingBlocksRef: ref<ContentBlock[]>(blocks),
})
)
const agent = ctx.state.model.nodes.get('span-1')
expect(agent?.kind).toBe('agent')
expect((agent as { status: string }).status).not.toBe('running')
})
it('does not clear the shared refs on a preserve-state stream', () => {
const streamingContentRef = ref('keep')
const streamingBlocksRef = ref<ContentBlock[]>([{ type: 'text', content: 'keep' }])
createStreamLoopContext(
makeStreamLoopDeps({
options: { preserveExistingState: true },
streamingContentRef,
streamingBlocksRef,
})
)
expect(streamingContentRef.current).toBe('keep')
})
})
describe('flush', () => {
it('no-ops when the stream is stale', () => {
const setPendingMessages = vi.fn()
const ctx = createStreamLoopContext(
makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2), setPendingMessages })
)
ctx.ops.flush()
expect(setPendingMessages).not.toHaveBeenCalled()
})
it('writes a pending-message snapshot when there is no chatId', () => {
const setPendingMessages = vi.fn()
const ctx = createStreamLoopContext(
makeStreamLoopDeps({ chatIdRef: ref<string | undefined>(undefined), setPendingMessages })
)
// flush serializes the model (the single source of truth) into the snapshot.
reduceEvent(ctx.state.model, textEnvelope('hello'))
ctx.ops.flush()
expect(setPendingMessages).toHaveBeenCalledTimes(1)
const updater = setPendingMessages.mock.calls[0][0] as (prev: ChatMessage[]) => ChatMessage[]
const result = updater([])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ id: 'assistant-1', role: 'assistant', content: 'hello' })
})
it('routes to mothership chat history when a chatId is present', () => {
const upsertMothershipChatHistory = vi.fn()
const ctx = createStreamLoopContext(
makeStreamLoopDeps({
chatIdRef: ref<string | undefined>('chat-1'),
upsertMothershipChatHistory,
})
)
ctx.state.runningText = 'hi'
ctx.ops.flush()
expect(upsertMothershipChatHistory).toHaveBeenCalledWith('chat-1', expect.any(Function))
})
})
describe('flushText (node falls through to flush synchronously)', () => {
it('no-ops when stale', () => {
const setPendingMessages = vi.fn()
const ctx = createStreamLoopContext(
makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2), setPendingMessages })
)
ctx.ops.flushText()
expect(setPendingMessages).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,264 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction'
import { captureRevealedSimKeys } from '@/lib/copilot/chat/sim-key-redaction'
import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import {
createTurnModel,
type TurnModel,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import {
contentBlocksToModel,
modelMainText,
modelToContentBlocks,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
import type {
ChatMessage,
ContentBlock,
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats'
export type ActiveTurn = {
userMessageId: string
assistantMessageId: string
optimisticUserMessage: ChatMessage
optimisticAssistantMessage: ChatMessage
}
export interface StreamLoopOptions {
preserveExistingState?: boolean
suppressedWorkflowToolStartIds?: ReadonlySet<string>
targetChatId?: string
shouldContinue?: () => boolean
}
export interface StreamLoopState {
/**
* The normalized turn model — the single source of truth for streamed state.
* `reduceEvent` folds every event into it; `flush` serializes it to the
* persisted/rendered `contentBlocks` shape. The handlers carry no block state.
*/
model: TurnModel
streamRequestId: string | undefined
sawStreamError: boolean
sawCompleteEvent: boolean
scheduledTextFlushFrame: number | null
}
export interface StreamEventScope {
scopedParentToolCallId: string | undefined
scopedAgentId: string | undefined
scopedSpanId: string | undefined
}
export interface StreamLoopDeps {
workspaceId: string
queryClient: QueryClient
assistantId: string
expectedGen: number | undefined
options: StreamLoopOptions
setError: Dispatch<SetStateAction<string | null>>
setPendingMessages: Dispatch<SetStateAction<ChatMessage[]>>
setResolvedChatId: Dispatch<SetStateAction<string | undefined>>
setResources: Dispatch<SetStateAction<MothershipResource[]>>
setActiveResourceId: Dispatch<SetStateAction<string | null>>
addResource: (resource: MothershipResource) => boolean
removeResource: (resourceType: MothershipResourceType, resourceId: string) => void
startClientWorkflowTool: (id: string, name: string, args: Record<string, unknown>) => void
upsertMothershipChatHistory: (
chatId: string,
updater: (current: MothershipChatHistory) => MothershipChatHistory
) => void
ensureWorkflowInRegistry: (resourceId: string, title: string, workspaceId: string) => boolean
onPreviewPhase: (payload: SyntheticFilePreviewPayload, streamId: string | undefined) => void
applyPreviewSessionUpdate: (
session: FilePreviewSession,
options?: { activate?: boolean }
) => unknown
removePreviewSessionImmediate: (sessionId: string) => unknown
promoteFileResource: (fileId: string, title: string) => void
shouldAutoActivatePreviewSession: (session: FilePreviewSession) => boolean
buildAssistantSnapshotMessage: (params: {
id: string
content: string
contentBlocks: ContentBlock[]
requestId?: string
}) => PersistedMessage
hasTerminalPersistedAssistantForStream: (
messages: PersistedMessage[],
streamId: string,
liveAssistantId: string
) => boolean
reconcileLiveAssistantTurn: (params: {
messages: PersistedMessage[]
streamId: string
liveAssistant: PersistedMessage
activeStreamId: string | null
}) => PersistedMessage[]
streamGenRef: MutableRefObject<number>
streamingBlocksRef: MutableRefObject<ContentBlock[]>
streamingContentRef: MutableRefObject<string>
chatIdRef: MutableRefObject<string | undefined>
selectedChatIdRef: MutableRefObject<string | undefined>
streamIdRef: MutableRefObject<string | undefined>
revealedSimKeysRef: MutableRefObject<RevealedSimKeysByMessage>
pendingUserMsgRef: MutableRefObject<PersistedMessage | null>
activeTurnRef: MutableRefObject<ActiveTurn | null>
resourcesRef: MutableRefObject<MothershipResource[]>
workflowIdRef: MutableRefObject<string | undefined>
activeResourceIdRef: MutableRefObject<string | null>
onTitleUpdateRef: MutableRefObject<(() => void) | undefined>
onToolResultRef: MutableRefObject<
((toolName: string, success: boolean, result: unknown) => void) | undefined
>
onResourceEventRef: MutableRefObject<(() => void) | undefined>
previewSessionRef: MutableRefObject<FilePreviewSession | null>
previewSessionsRef: MutableRefObject<Record<string, FilePreviewSession>>
latestPreviewTargetToolCallIdRef: MutableRefObject<string | null>
activePreviewSessionIdRef: MutableRefObject<string | null>
completedPreviewResourceHandoffRef: MutableRefObject<
Map<string, { sessionId: string; suppressActivation: boolean }>
>
previewActivationOwnerRef: MutableRefObject<Map<string, string | null>>
}
export interface StreamLoopOps {
isStale: () => boolean
flush: () => void
flushText: () => void
}
export interface StreamLoopContext {
state: StreamLoopState
ops: StreamLoopOps
deps: StreamLoopDeps
}
/**
* Builds the per-stream context for `processSSEStream`: the mutable accumulation
* state, the bound operations the event handlers share (block builders, `flush`,
* staleness), and the injected hook dependencies. A fresh context is created per
* stream invocation so overlapping/superseded streams never cross-write state;
* `isStale` carries the exact generation + `shouldContinue` guard, and the
* `preserveExistingState` reconnect path rehydrates blocks, the tool index, and
* the subagent indexes from the supplied refs.
*/
export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext {
const preserveState = deps.options.preserveExistingState === true
const state: StreamLoopState = {
// On a reconnect that preserves state, rebuild the model from the last
// serialized snapshot so live events fold into the identical model.
model: preserveState
? contentBlocksToModel(deps.streamingBlocksRef.current)
: createTurnModel(),
streamRequestId: undefined,
sawStreamError: false,
sawCompleteEvent: false,
scheduledTextFlushFrame: null,
}
const isStale = () =>
(deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) ||
deps.options.shouldContinue?.() === false
if (!preserveState && !isStale()) {
deps.streamingContentRef.current = ''
deps.streamingBlocksRef.current = []
}
const flush = () => {
if (isStale()) return
// The model is authoritative: serialize it to the persisted/rendered block
// shape and main-lane content for every snapshot write.
const modelBlocks = modelToContentBlocks(state.model)
const modelContent = modelMainText(state.model)
deps.streamingBlocksRef.current = modelBlocks
deps.streamingContentRef.current = modelContent
captureRevealedSimKeys(
deps.revealedSimKeysRef.current,
[deps.assistantId, state.streamRequestId],
modelContent
)
const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current
if (!activeChatId) {
const snapshot: Partial<ChatMessage> = {
content: modelContent,
contentBlocks: modelBlocks,
}
if (state.streamRequestId) snapshot.requestId = state.streamRequestId
deps.setPendingMessages((prev) => {
if (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) {
return prev
}
const idx = prev.findIndex((m) => m.id === deps.assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === deps.assistantId ? { ...m, ...snapshot } : m))
}
return [
...prev,
{ id: deps.assistantId, role: 'assistant' as const, content: '', ...snapshot },
]
})
return
}
const assistantMessage = deps.buildAssistantSnapshotMessage({
id: deps.assistantId,
content: modelContent,
contentBlocks: modelBlocks,
...(state.streamRequestId ? { requestId: state.streamRequestId } : {}),
})
deps.upsertMothershipChatHistory(activeChatId, (current) => {
const streamId = deps.streamIdRef.current ?? current.activeStreamId ?? deps.assistantId
const terminalPersistedAssistantExists =
current.activeStreamId !== streamId &&
deps.hasTerminalPersistedAssistantForStream(current.messages, streamId, assistantMessage.id)
const reconciledMessages = deps.reconcileLiveAssistantTurn({
messages: current.messages,
streamId,
liveAssistant: assistantMessage,
activeStreamId: current.activeStreamId,
})
const skippedTerminalLiveWrite = reconciledMessages === current.messages
return {
...current,
messages: reconciledMessages,
activeStreamId:
skippedTerminalLiveWrite || terminalPersistedAssistantExists
? current.activeStreamId
: (deps.streamIdRef.current ?? current.activeStreamId),
}
})
}
const flushText = () => {
if (isStale()) return
if (state.scheduledTextFlushFrame !== null) return
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
flush()
return
}
state.scheduledTextFlushFrame = window.requestAnimationFrame(() => {
state.scheduledTextFlushFrame = null
flush()
})
}
const ops: StreamLoopOps = {
isStale,
flush,
flushText,
}
return { state, ops, deps }
}
@@ -0,0 +1,380 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import {
CrawlWebsite,
DeleteWorkflow,
DeployApi,
DeployChat,
DeployMcp,
FunctionExecute,
Glob,
Grep,
ManageCredential,
ManageCredentialOperation,
ManageCustomTool,
ManageCustomToolOperation,
ManageFolder,
ManageFolderOperation,
ManageMcpTool,
ManageMcpToolOperation,
ManageScheduledTask,
ManageScheduledTaskOperation,
ManageSkill,
ManageSkillOperation,
MoveWorkflow,
QueryLogs,
Redeploy,
RenameWorkflow,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
ScrapePage,
SearchOnline,
WorkspaceFile,
WorkspaceFileOperation,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display'
import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('StreamHelpers')
export const FILE_SUBAGENT_ID = 'file'
export const DEPLOY_TOOL_NAMES: Set<string> = new Set([
DeployApi.id,
DeployChat.id,
DeployMcp.id,
Redeploy.id,
])
export const FOLDER_TOOL_NAMES: Set<string> = new Set([ManageFolder.id])
export const WORKFLOW_MUTATION_TOOL_NAMES: Set<string> = new Set([
MoveWorkflow.id,
RenameWorkflow.id,
DeleteWorkflow.id,
])
export type StreamPayload = Record<string, unknown>
export function asPayloadRecord(value: unknown): StreamPayload | undefined {
return isRecordLike(value) ? value : undefined
}
/**
* Settles any tool row still `executing` at a turn terminal by propagating the
* turn's outcome — the deterministic replacement for the old `interrupted`
* invention. A clean `complete` means the turn succeeded, so a straggler is
* settled `success` (with explicit tool/span terminals from the backend there
* are normally none); a stop settles `cancelled`; an error settles `error`.
*/
export function finalizeResidualToolCalls(
blocks: ContentBlock[],
turnTerminal: 'complete' | 'cancelled' | 'error'
): void {
const endedAt = Date.now()
const propagated =
turnTerminal === 'cancelled'
? ToolCallStatus.cancelled
: turnTerminal === 'error'
? ToolCallStatus.error
: ToolCallStatus.success
for (const block of blocks) {
// Close any still-open subagent lane at the turn terminal so its group
// resolves deterministically even when the backend cut off before a
// `span end` (abort/disconnect). The projection treats a stamped `endedAt`
// as a closed group, so the delegating spinner clears without any
// transport-based gating.
if (block.type === 'subagent' && block.endedAt === undefined) {
block.endedAt = endedAt
continue
}
const tc = block.toolCall
if (!tc || tc.status !== ToolCallStatus.executing) continue
tc.status = propagated
if (propagated === ToolCallStatus.cancelled) {
tc.displayTitle = 'Stopped by user'
}
if (block.endedAt === undefined) {
block.endedAt = endedAt
}
}
}
function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined {
const lastSegment = segments[segments.length - 1]
if (!lastSegment) return undefined
if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) {
return segments[segments.length - 2]
}
return lastSegment
}
export function extractResourceFromReadResult(
path: string | undefined,
output: unknown
): MothershipResource | null {
if (!path) return null
const segments = path
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType || !segments[1]) return null
const obj = output && typeof output === 'object' ? (output as Record<string, unknown>) : undefined
if (!obj) return null
let id = obj.id as string | undefined
let name = obj.name as string | undefined
if (!id && typeof obj.content === 'string') {
try {
const parsed = JSON.parse(obj.content)
id = parsed?.id as string | undefined
name = parsed?.name as string | undefined
} catch {}
}
const fallbackTitle =
resourceType === 'workflow'
? resolveLeafWorkflowPathSegment(segments)
: segments[1] || segments[segments.length - 1]
if (!id) return null
return { type: resourceType, id, title: name || fallbackTitle || id }
}
function stringParam(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined {
const id = stringParam(workflowId)
if (!id) return undefined
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return undefined
return getWorkflowById(workspaceId, id)?.name
}
function resolveBlockNameForDisplay(blockId: unknown): string | undefined {
const id = stringParam(blockId)
if (!id) return undefined
return useWorkflowStore.getState().blocks[id]?.name
}
function resolveWorkspaceFileDisplayTitle(
operation: unknown,
title: unknown,
targetFileName?: unknown
): string | undefined {
const chunkTitle = stringParam(title)
const fileName = stringParam(targetFileName)
let verb = 'Writing'
switch (operation) {
case WorkspaceFileOperation.append:
verb = 'Adding'
break
case WorkspaceFileOperation.patch:
verb = 'Editing'
break
case WorkspaceFileOperation.update:
verb = 'Writing'
break
}
if (chunkTitle) return `${verb} ${chunkTitle}`
if (fileName) return `${verb} ${fileName}`
return undefined
}
function resolveOperationDisplayTitle(
operation: unknown,
labels: Partial<Record<string, string>>,
fallback: string
): string {
const label = typeof operation === 'string' ? labels[operation] : undefined
return label ?? fallback
}
function functionExecuteTitle(title: string | undefined): string {
return title ?? 'Running code'
}
export function resolveToolDisplayTitle(name: string, args?: Record<string, unknown>): string {
// Cases that enrich the title with live workspace/block names from the client
// stores. Everything else is resolved by the shared name+args resolver, which
// is the single source of truth for tool-call titles.
if (name === RunWorkflow.id) {
const workflowName = resolveWorkflowNameForDisplay(args?.workflowId)
return workflowName ? `Running ${workflowName}` : 'Running workflow'
}
if (name === RunFromBlock.id) {
const workflowName = resolveWorkflowNameForDisplay(args?.workflowId)
const blockName = resolveBlockNameForDisplay(args?.startBlockId)
if (workflowName && blockName) return `Running ${workflowName} from ${blockName}`
if (workflowName) return `Running ${workflowName}`
if (blockName) return `Running from ${blockName}`
return 'Running workflow'
}
if (name === RunWorkflowUntilBlock.id) {
const workflowName = resolveWorkflowNameForDisplay(args?.workflowId)
const blockName = resolveBlockNameForDisplay(args?.stopAfterBlockId)
if (workflowName && blockName) return `Running ${workflowName} until ${blockName}`
if (workflowName) return `Running ${workflowName}`
if (blockName) return `Running until ${blockName}`
return 'Running workflow'
}
if (name === QueryLogs.id) {
const workflowName =
resolveWorkflowNameForDisplay(args?.workflowId) ?? stringParam(args?.workflowName)
if (workflowName) return `Querying logs for ${workflowName}`
}
return getToolDisplayTitle(name, args)
}
function decodeStreamingString(value: string): string {
return value
.replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16))
)
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
function matchStreamingStringArg(streamingArgs: string, key: string): string | undefined {
const match = streamingArgs.match(new RegExp(`"${key}"\\s*:\\s*"([^"]*)"`, 'm'))
return match?.[1] ? decodeStreamingString(match[1]) : undefined
}
export function resolveStreamingToolDisplayTitle(
name: string,
streamingArgs: string
): string | undefined {
if (name === FunctionExecute.id) {
return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title'))
}
if (name === WorkspaceFile.id) {
return resolveWorkspaceFileDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
matchStreamingStringArg(streamingArgs, 'title'),
matchStreamingStringArg(streamingArgs, 'fileName')
)
}
if (name === SearchOnline.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching online for ${toolTitle}` : undefined
}
if (name === Grep.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching for ${toolTitle}` : undefined
}
if (name === Glob.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Finding ${toolTitle}` : undefined
}
if (name === ScrapePage.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Scraping ${url}` : undefined
}
if (name === CrawlWebsite.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Crawling ${url}` : undefined
}
if (name === ManageCustomTool.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageCustomToolOperation.add]: 'Creating custom tool',
[ManageCustomToolOperation.edit]: 'Updating custom tool',
[ManageCustomToolOperation.delete]: 'Deleting custom tool',
[ManageCustomToolOperation.list]: 'Listing custom tools',
},
'Custom tool action'
)
}
if (name === ManageMcpTool.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageMcpToolOperation.add]: 'Creating MCP server',
[ManageMcpToolOperation.edit]: 'Updating MCP server',
[ManageMcpToolOperation.delete]: 'Deleting MCP server',
[ManageMcpToolOperation.list]: 'Listing MCP servers',
},
'MCP server action'
)
}
if (name === ManageSkill.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageSkillOperation.add]: 'Creating skill',
[ManageSkillOperation.edit]: 'Updating skill',
[ManageSkillOperation.delete]: 'Deleting skill',
[ManageSkillOperation.list]: 'Listing skills',
},
'Skill action'
)
}
if (name === ManageScheduledTask.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageScheduledTaskOperation.create]: 'Creating scheduled task',
[ManageScheduledTaskOperation.get]: 'Getting scheduled task',
[ManageScheduledTaskOperation.update]: 'Updating scheduled task',
[ManageScheduledTaskOperation.delete]: 'Deleting scheduled task',
[ManageScheduledTaskOperation.list]: 'Listing scheduled tasks',
},
'Scheduled task action'
)
}
if (name === ManageCredential.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageCredentialOperation.rename]: 'Renaming credential',
[ManageCredentialOperation.delete]: 'Deleting credential',
},
'Credential action'
)
}
if (name === ManageFolder.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageFolderOperation.create]: 'Creating folder',
[ManageFolderOperation.rename]: 'Renaming folder',
[ManageFolderOperation.move]: 'Moving folder',
[ManageFolderOperation.delete]: 'Deleting folder',
},
'Folder action'
)
}
return undefined
}
@@ -0,0 +1,88 @@
import type { MutableRefObject } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { vi } from 'vitest'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import type {
ActiveTurn,
StreamLoopDeps,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
/** Minimal {@link MutableRefObject} factory for stream-loop unit fixtures. */
export function ref<T>(current: T): MutableRefObject<T> {
return { current }
}
/**
* Builds a fully-stubbed {@link StreamLoopDeps} for stream-loop unit tests.
* Every function is a `vi.fn` and every ref is seeded with an empty value;
* tests override only the fields relevant to the behavior under test.
*/
export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): StreamLoopDeps {
return {
workspaceId: 'ws-1',
queryClient: {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
// double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures
} as unknown as QueryClient,
assistantId: 'assistant-1',
expectedGen: 1,
options: {},
setError: vi.fn(),
setPendingMessages: vi.fn(),
setResolvedChatId: vi.fn(),
setResources: vi.fn(),
setActiveResourceId: vi.fn(),
addResource: vi.fn(() => true),
removeResource: vi.fn(),
startClientWorkflowTool: vi.fn(),
upsertMothershipChatHistory: vi.fn(),
ensureWorkflowInRegistry: vi.fn(() => false),
onPreviewPhase: vi.fn(),
applyPreviewSessionUpdate: vi.fn(),
removePreviewSessionImmediate: vi.fn(),
promoteFileResource: vi.fn(),
shouldAutoActivatePreviewSession: vi.fn(() => true),
buildAssistantSnapshotMessage: vi.fn(({ id, content, contentBlocks, requestId }) => ({
id,
role: 'assistant',
content,
contentBlocks,
...(requestId ? { requestId } : {}),
// double-cast-allowed: vi.fn wrapper loses the exact snapshot-builder signature in this test fixture
})) as unknown as StreamLoopDeps['buildAssistantSnapshotMessage'],
hasTerminalPersistedAssistantForStream: vi.fn(() => false),
reconcileLiveAssistantTurn: vi.fn(
(params: { messages: PersistedMessage[] }) => params.messages
),
streamGenRef: ref(1),
streamingBlocksRef: ref<ContentBlock[]>([]),
streamingContentRef: ref(''),
chatIdRef: ref<string | undefined>(undefined),
selectedChatIdRef: ref<string | undefined>(undefined),
streamIdRef: ref<string | undefined>(undefined),
revealedSimKeysRef: ref<RevealedSimKeysByMessage>(new Map()),
pendingUserMsgRef: ref<PersistedMessage | null>(null),
activeTurnRef: ref<ActiveTurn | null>(null),
resourcesRef: ref<MothershipResource[]>([]),
workflowIdRef: ref<string | undefined>(undefined),
activeResourceIdRef: ref<string | null>(null),
onTitleUpdateRef: ref<(() => void) | undefined>(undefined),
onToolResultRef: ref<
((toolName: string, success: boolean, result: unknown) => void) | undefined
>(undefined),
onResourceEventRef: ref<(() => void) | undefined>(undefined),
previewSessionRef: ref<FilePreviewSession | null>(null),
previewSessionsRef: ref<Record<string, FilePreviewSession>>({}),
latestPreviewTargetToolCallIdRef: ref<string | null>(null),
activePreviewSessionIdRef: ref<string | null>(null),
completedPreviewResourceHandoffRef: ref<
Map<string, { sessionId: string; suppressActivation: boolean }>
>(new Map()),
previewActivationOwnerRef: ref<Map<string, string | null>>(new Map()),
...overrides,
}
}
@@ -0,0 +1,408 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
type AgentNode,
applyTurnTerminal,
createTurnModel,
reduceEvent,
type ToolNode,
type TurnModel,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import {
contentBlocksToModel,
modelToContentBlocks,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
interface Scope {
lane: 'subagent'
spanId?: string
parentSpanId?: string
parentToolCallId?: string
agentId?: string
}
function env(seq: number, type: string, payload: Record<string, unknown>, scope?: Scope) {
return {
v: 1,
seq,
// Real ts so tsMs === seq, exercising the wall-clock timing path.
ts: new Date(seq).toISOString(),
stream: { streamId: 's1', cursor: String(seq) },
type,
payload,
...(scope ? { scope } : {}),
} as unknown as PersistedStreamEventEnvelope
}
function build(events: PersistedStreamEventEnvelope[]): TurnModel {
const m = createTurnModel()
for (const e of events) reduceEvent(m, e)
return m
}
// A main-agent file delegation: trigger tool (main lane), subagent span, inner
// workspace_file, span end, delegation result.
function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
const sub: Scope = {
lane: 'subagent',
spanId: 'S1',
parentSpanId: 'main',
parentToolCallId: 'tc-file',
agentId: 'file',
}
return [
env(1, 'text', { channel: 'assistant', text: 'Writing the file.' }),
env(2, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }),
env(
3,
'span',
{ kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } },
sub
),
env(
4,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
5,
'tool',
{ phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true },
{ lane: 'subagent', spanId: 'S1' }
),
env(
6,
'span',
{ kind: 'subagent', event: 'end', agent: 'file', data: {} },
{ lane: 'subagent', spanId: 'S1' }
),
env(7, 'tool', { phase: 'result', toolCallId: 'tc-file', toolName: 'file', success: true }),
]
}
function blocksByType(blocks: ReturnType<typeof modelToContentBlocks>, type: string) {
return blocks.filter((b) => b.type === type)
}
describe('modelToContentBlocks', () => {
it('emits main-lane blocks without spanId and subagent-lane blocks with spanId', () => {
const blocks = modelToContentBlocks(build(fileDelegationEvents()))
const mainText = blocks.find((b) => b.type === 'text')
expect(mainText?.spanId).toBeUndefined()
const trigger = blocksByType(blocks, 'tool_call').find((b) => b.toolCall?.name === 'file')
expect(trigger?.spanId).toBeUndefined()
expect(trigger?.toolCall?.status).toBe('success')
const innerTool = blocksByType(blocks, 'tool_call').find(
(b) => b.toolCall?.name === 'workspace_file'
)
expect(innerTool?.spanId).toBe('S1')
expect(innerTool?.toolCall?.calledBy).toBe('file')
expect(innerTool?.toolCall?.status).toBe('success')
const subagent = blocks.find((b) => b.type === 'subagent')
expect(subagent?.spanId).toBe('S1')
expect(subagent?.parentSpanId).toBe('main')
expect(subagent?.parentToolCallId).toBe('tc-file')
})
it('orders blocks by wire seq and appends new content without reordering existing blocks', () => {
const m = createTurnModel()
reduceEvent(m, env(1, 'text', { channel: 'assistant', text: 'one' }))
reduceEvent(m, env(2, 'tool', { phase: 'call', toolCallId: 't1', toolName: 'search' }))
const snap1 = modelToContentBlocks(m)
expect(snap1.map((b) => b.type)).toEqual(['text', 'tool_call'])
// Later events arrive; the tool settles and new text starts.
reduceEvent(
m,
env(3, 'tool', { phase: 'result', toolCallId: 't1', toolName: 'search', success: true })
)
reduceEvent(m, env(4, 'text', { channel: 'assistant', text: 'two' }))
const snap2 = modelToContentBlocks(m)
// Existing blocks keep their position (snap1 is a prefix of snap2); new text appends.
expect(snap2.map((b) => b.type)).toEqual(['text', 'tool_call', 'text'])
expect(snap2[1].toolCall?.id).toBe('t1')
expect(snap2[0].content).toBe('one')
})
it('attributes subagent content that streams before its subagent_start (parallel-burst inversion)', () => {
const sub: Scope = {
lane: 'subagent',
spanId: 'R1',
parentSpanId: 'main',
parentToolCallId: 'tc-r1',
agentId: 'research',
}
const m = createTurnModel()
reduceEvent(m, env(1, 'text', { channel: 'assistant', text: 'Spawning research.' }))
// Under an 8-way burst the subagent's thinking + text can be reduced before
// its subagent_start lands. The content already carries the lane identity.
reduceEvent(m, env(2, 'text', { channel: 'thinking', text: 'Considering odds.' }, sub))
reduceEvent(m, env(3, 'text', { channel: 'assistant', text: 'Team analysis.' }, sub))
// Snapshot mid-burst (before the start): the research content must already be
// its own lane, never leaked into the main ("Sim") lane with its thinking dropped.
const mid = modelToContentBlocks(m)
const midSub = mid.find((b) => b.type === 'subagent')
expect(midSub?.content).toBe('research')
expect(midSub?.spanId).toBe('R1')
expect(mid.find((b) => b.type === 'subagent_thinking')?.spanId).toBe('R1')
expect(mid.filter((b) => b.type === 'text' && b.spanId === 'R1')).toHaveLength(1)
// The main lane holds only the pre-spawn text — nothing leaked in.
const mainText = mid.filter((b) => b.type === 'text' && !b.spanId)
expect(mainText).toHaveLength(1)
expect(mainText[0].content).toBe('Spawning research.')
// The real subagent_start lands afterward and no-ops: still one research lane.
reduceEvent(
m,
env(
4,
'span',
{ kind: 'subagent', event: 'start', agent: 'research', data: { tool_call_id: 'tc-r1' } },
sub
)
)
const after = modelToContentBlocks(m)
expect(after.filter((b) => b.type === 'subagent')).toHaveLength(1)
expect(after.find((b) => b.type === 'subagent')?.content).toBe('research')
})
it('places subagent_end at its end seq (after the lane work), never reordering siblings', () => {
const sub: Scope = {
lane: 'subagent',
spanId: 'S1',
parentToolCallId: 'tc-file',
agentId: 'file',
}
const blocks = modelToContentBlocks(
build([
env(1, 'text', { channel: 'assistant', text: 'before' }),
env(2, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }),
env(
3,
'span',
{ kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } },
sub
),
env(
4,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
5,
'span',
{ kind: 'subagent', event: 'end', agent: 'file', data: {} },
{ lane: 'subagent', spanId: 'S1' }
),
env(6, 'text', { channel: 'assistant', text: 'after' }),
])
)
const types = blocks.map((b) => b.type)
const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file')
const endIdx = types.indexOf('subagent_end')
const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after')
// subagent_end sits after the inner work and before the trailing main text — no sibling jumps.
expect(endIdx).toBeGreaterThan(innerIdx)
expect(afterIdx).toBeGreaterThan(endIdx)
})
it('preserves thinking timing across a model -> blocks -> model reconnect round-trip', () => {
const m1 = build([
env(1, 'text', { channel: 'thinking', text: 'pondering' }),
env(2, 'text', { channel: 'assistant', text: 'the answer' }),
])
const blocks1 = modelToContentBlocks(m1)
const blocks2 = modelToContentBlocks(contentBlocksToModel(blocks1))
const t1 = blocks1.find((b) => b.type === 'thinking')
const t2 = blocks2.find((b) => b.type === 'thinking')
expect(t1?.timestamp).toBe(1)
expect(t1?.endedAt).toBe(2)
// Reconnect rebuild must not reset timing to seq/undefined.
expect(t2?.timestamp).toBe(t1?.timestamp)
expect(t2?.endedAt).toBe(t1?.endedAt)
})
it('emits subagent_end for a straggler lane closed by a model terminal (no span end)', () => {
const sub: Scope = {
lane: 'subagent',
spanId: 'S1',
parentToolCallId: 'tc-file',
agentId: 'file',
}
const m = build([
env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }),
env(
2,
'span',
{ kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } },
sub
),
env(
3,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ lane: 'subagent', spanId: 'S1' }
),
])
applyTurnTerminal(m, 'error')
const blocks = modelToContentBlocks(m)
expect(blocks.some((b) => b.type === 'subagent_end' && b.spanId === 'S1')).toBe(true)
})
it('skips per-call hidden tool nodes but keeps them in the model for side effects', () => {
const m = build([
env(1, 'tool', {
phase: 'call',
toolCallId: 'h-1',
toolName: 'secret_tool',
ui: { hidden: true },
}),
env(2, 'tool', {
phase: 'result',
toolCallId: 'h-1',
toolName: 'secret_tool',
success: true,
}),
])
expect(m.nodes.has('h-1')).toBe(true)
expect(blocksByType(modelToContentBlocks(m), 'tool_call')).toHaveLength(0)
})
it('resolves a tool display title from its arguments', () => {
const blocks = modelToContentBlocks(
build([
env(1, 'tool', {
phase: 'call',
toolCallId: 'wf',
toolName: 'workspace_file',
arguments: { operation: 'create', title: 'My Doc' },
}),
])
)
const tool = blocksByType(blocks, 'tool_call').find((b) => b.toolCall?.id === 'wf')
expect(tool?.toolCall?.displayTitle).toBeTruthy()
})
it('emits a paired subagent_end at the run end seq, ordered after the inner work', () => {
const blocks = modelToContentBlocks(build(fileDelegationEvents()))
const startIdx = blocks.findIndex((b) => b.type === 'subagent')
const innerIdx = blocks.findIndex(
(b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file'
)
const endIdx = blocks.findIndex((b) => b.type === 'subagent_end')
expect(startIdx).toBeGreaterThanOrEqual(0)
expect(endIdx).toBeGreaterThan(innerIdx)
expect(innerIdx).toBeGreaterThan(startIdx)
})
it('omits subagent_end while the run is still open', () => {
const sub: Scope = {
lane: 'subagent',
spanId: 'S1',
parentSpanId: 'main',
parentToolCallId: 'tc-file',
agentId: 'file',
}
const blocks = modelToContentBlocks(
build([
env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }),
env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, sub),
])
)
expect(blocksByType(blocks, 'subagent_end')).toHaveLength(0)
expect(blocksByType(blocks, 'subagent')).toHaveLength(1)
})
})
describe('contentBlocksToModel round-trip', () => {
function tool(model: TurnModel, id: string): ToolNode {
return model.nodes.get(id) as ToolNode
}
function agent(model: TurnModel, spanId: string): AgentNode {
return model.nodes.get(spanId) as AgentNode
}
it('rebuilds tool and agent statuses and nesting from serialized blocks', () => {
const original = build(fileDelegationEvents())
const rebuilt = contentBlocksToModel(modelToContentBlocks(original))
expect(tool(rebuilt, 'tc-file').status).toBe('success')
expect(tool(rebuilt, 'wf-1').status).toBe('success')
expect(tool(rebuilt, 'wf-1').spanId).toBe('S1')
expect(agent(rebuilt, 'S1').status).toBe('success')
expect(agent(rebuilt, 'S1').parentSpanId).toBe('main')
expect(agent(rebuilt, 'S1').triggerToolCallId).toBe('tc-file')
})
it('preserves a running tool and an open subagent across the round-trip', () => {
const sub: Scope = {
lane: 'subagent',
spanId: 'S1',
parentSpanId: 'main',
parentToolCallId: 'tc-file',
agentId: 'file',
}
const original = build([
env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }),
env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, sub),
env(
3,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ lane: 'subagent', spanId: 'S1' }
),
])
const rebuilt = contentBlocksToModel(modelToContentBlocks(original))
expect(tool(rebuilt, 'wf-1').status).toBe('running')
expect(agent(rebuilt, 'S1').status).toBe('running')
})
it('round-trips parallel same-name subagents on distinct spans', () => {
const subA: Scope = {
lane: 'subagent',
spanId: 'SA',
parentSpanId: 'main',
parentToolCallId: 'tc-a',
agentId: 'file',
}
const subB: Scope = {
lane: 'subagent',
spanId: 'SB',
parentSpanId: 'main',
parentToolCallId: 'tc-b',
agentId: 'file',
}
const original = build([
env(1, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, subA),
env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, subB),
env(
3,
'span',
{ kind: 'subagent', event: 'end', agent: 'file', data: {} },
{ lane: 'subagent', spanId: 'SA' }
),
env(
4,
'span',
{ kind: 'subagent', event: 'end', agent: 'file', data: {} },
{ lane: 'subagent', spanId: 'SB' }
),
])
const rebuilt = contentBlocksToModel(modelToContentBlocks(original))
expect(agent(rebuilt, 'SA').triggerToolCallId).toBe('tc-a')
expect(agent(rebuilt, 'SB').triggerToolCallId).toBe('tc-b')
expect(agent(rebuilt, 'SA').status).toBe('success')
expect(agent(rebuilt, 'SB').status).toBe('success')
})
})
@@ -0,0 +1,337 @@
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
resolveStreamingToolDisplayTitle,
resolveToolDisplayTitle,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers'
import {
type AgentNode,
createTurnModel,
MAIN_SPAN,
type NodeStatus,
reduceEvent,
type ToolNode,
type TurnModel,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
/**
* Serialization bridge between the normalized {@link TurnModel} (the streaming
* source of truth) and the persisted/rendered `ContentBlock[]` shape. The model
* is authoritative during streaming; `flush` serializes it to blocks for the
* React-Query/pending snapshot and the DB, and the renderer keeps projecting
* blocks via the existing `parseBlocks`. `contentBlocksToModel` rebuilds the
* model from a persisted snapshot so a reconnect mid-stream continues into the
* exact same model.
*/
function nodeToToolStatus(status: NodeStatus): ToolCallStatus {
if (status === 'running') return ToolCallStatus.executing
return status
}
function toolStatusToNode(status: ToolCallStatus): NodeStatus {
if (status === ToolCallStatus.executing) return 'running'
if (status === ToolCallStatus.interrupted) return 'error'
return status
}
/**
* Resolves a tool row's display title with the same precedence the live handler
* used: the streaming-args title wins while args stream, then the arg-derived
* title, then the explicit `ui.title`.
*/
function toolDisplayTitle(node: ToolNode): string | undefined {
const streamingTitle = node.streamingArgs
? resolveStreamingToolDisplayTitle(node.name, node.streamingArgs)
: undefined
return streamingTitle ?? resolveToolDisplayTitle(node.name, node.args) ?? node.uiTitle
}
interface SeqBlock {
seq: number
block: ContentBlock
}
/**
* Serializes the model to ordered content blocks matching the live handler
* shapes: main-lane blocks carry no `spanId`; subagent-lane blocks carry
* `spanId`/`parentSpanId` (and `subagent` name for text). A terminated agent
* emits a paired `subagent_end` at its end seq so the projection closes the lane
* exactly as the live browser path did.
*/
export function modelToContentBlocks(model: TurnModel): ContentBlock[] {
const entries: SeqBlock[] = []
for (const id of model.order) {
const node = model.nodes.get(id)
if (!node) continue
const isSub = node.spanId !== MAIN_SPAN
const ownerAgent = isSub ? (model.nodes.get(node.spanId) as AgentNode | undefined) : undefined
const spanFields = isSub
? {
spanId: node.spanId,
...(ownerAgent ? { parentSpanId: ownerAgent.parentSpanId } : {}),
}
: {}
if (node.kind === 'text') {
if (!node.text) continue
// Real wall-clock timing drives the thinking-duration UI ("Thought for Ns"
// + the 3s active-suppression); fall back to seq when ts was unavailable.
const timing = {
timestamp: node.startedAtMs ?? node.seq,
...(node.endedAtMs !== undefined ? { endedAt: node.endedAtMs } : {}),
}
if (node.channel === 'thinking') {
entries.push({
seq: node.seq,
block: isSub
? {
type: 'subagent_thinking',
content: node.text,
...(ownerAgent ? { subagent: ownerAgent.agentId } : {}),
...spanFields,
...timing,
}
: { type: 'thinking', content: node.text, ...timing },
})
} else {
entries.push({
seq: node.seq,
block: isSub
? {
type: 'text',
content: node.text,
...(ownerAgent ? { subagent: ownerAgent.agentId } : {}),
...spanFields,
...timing,
}
: { type: 'text', content: node.text, ...timing },
})
}
continue
}
if (node.kind === 'tool') {
// Per-call hidden tools are tracked for side effects but never rendered.
if (node.hidden) continue
const displayTitle = toolDisplayTitle(node)
entries.push({
seq: node.seq,
block: {
type: 'tool_call',
toolCall: {
id: node.id,
name: node.name,
status: nodeToToolStatus(node.status),
...(displayTitle ? { displayTitle } : {}),
...(node.args ? { params: node.args } : {}),
...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}),
...(node.result
? {
result: {
success: node.result.success,
...(node.result.output !== undefined ? { output: node.result.output } : {}),
...(node.result.error ? { error: node.result.error } : {}),
},
}
: {}),
...(isSub && ownerAgent ? { calledBy: ownerAgent.agentId } : {}),
},
...spanFields,
// Wall-clock when available (uniform with text); falls back to seq.
timestamp: node.startedAtMs ?? node.seq,
},
})
continue
}
// Agent node -> a `subagent` open block, plus a `subagent_end` at end seq.
entries.push({
seq: node.seq,
block: {
type: 'subagent',
content: node.agentId,
spanId: node.spanId,
parentSpanId: node.parentSpanId,
...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}),
timestamp: node.startedAtMs ?? node.seq,
},
})
if (node.endSeq !== undefined) {
entries.push({
seq: node.endSeq,
block: {
type: 'subagent_end',
spanId: node.spanId,
parentSpanId: node.parentSpanId,
...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}),
timestamp: node.endSeq,
},
})
}
}
entries.sort((a, b) => a.seq - b.seq)
return entries.map((e) => e.block)
}
/** Returns the assistant-channel text of the main lane, in order (snapshot `content`). */
export function modelMainText(model: TurnModel): string {
let text = ''
for (const id of model.order) {
const node = model.nodes.get(id)
if (node?.kind === 'text' && node.spanId === MAIN_SPAN && node.channel === 'assistant') {
text += node.text
}
}
return text
}
/**
* Rebuilds a model from a persisted/live snapshot of content blocks. Used when a
* reconnect resumes a stream whose model is not in memory (page reload mid-turn):
* the snapshot is replayed as synthetic envelopes so subsequent live events fold
* into the identical model. Operates on the live, span-carrying block shape.
*/
export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
const model = createTurnModel()
let seq = 0
const synth = (
type: string,
payload: Record<string, unknown>,
scope?: Record<string, unknown>,
tsMs?: number
): PersistedStreamEventEnvelope =>
({
v: 1,
seq: ++seq,
// Carry the persisted wall-clock so the rebuilt model keeps real timing
// (thinking duration / 3s suppression) across a reconnect rebuild.
ts: tsMs !== undefined ? new Date(tsMs).toISOString() : '',
stream: { streamId: '', cursor: String(seq) },
type,
payload,
...(scope ? { scope } : {}),
// double-cast-allowed: synthetic replay envelope rebuilt from ContentBlocks for reduceEvent only; payloads are intentionally the minimal shape the reducer reads (no executor/mode), never provider-parsed or re-emitted on the wire
}) as unknown as PersistedStreamEventEnvelope
const scopeFor = (block: ContentBlock): Record<string, unknown> | undefined =>
block.spanId
? {
lane: 'subagent',
spanId: block.spanId,
...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}),
...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}),
...(block.subagent ? { agentId: block.subagent } : {}),
}
: undefined
for (const block of blocks) {
if (block.type === 'subagent') {
reduceEvent(
model,
synth(
'span',
{
kind: 'subagent',
event: 'start',
agent: block.content,
data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {},
},
scopeFor(block),
block.timestamp
)
)
if (block.endedAt !== undefined) {
reduceEvent(
model,
synth(
'span',
{ kind: 'subagent', event: 'end', agent: block.content, data: {} },
scopeFor(block),
block.endedAt
)
)
}
continue
}
if (block.type === 'subagent_end') {
reduceEvent(
model,
synth('span', { kind: 'subagent', event: 'end', agent: '', data: {} }, scopeFor(block))
)
continue
}
if (block.type === 'tool_call' && block.toolCall) {
const tc = block.toolCall
reduceEvent(
model,
synth(
'tool',
{
phase: 'call',
toolCallId: tc.id,
toolName: tc.name,
arguments: tc.params,
// Preserve a server-provided title that isn't derivable from args.
...(tc.displayTitle ? { ui: { title: tc.displayTitle } } : {}),
},
scopeFor(block),
block.timestamp
)
)
if (tc.status !== ToolCallStatus.executing) {
const node = toolStatusToNode(tc.status)
reduceEvent(
model,
synth(
'tool',
{
phase: 'result',
toolCallId: tc.id,
toolName: tc.name,
success: node === 'success',
status: node,
output: tc.result?.output,
// Carry the failure message so a reloaded failed tool keeps it.
...(tc.result?.error ? { error: tc.result.error } : {}),
},
scopeFor(block)
)
)
}
continue
}
if (block.type === 'text' || block.type === 'subagent_text') {
if (block.content) {
reduceEvent(
model,
synth(
'text',
{ channel: 'assistant', text: block.content },
scopeFor(block),
block.timestamp
)
)
}
continue
}
if (block.type === 'thinking' || block.type === 'subagent_thinking') {
if (block.content) {
reduceEvent(
model,
synth(
'text',
{ channel: 'thinking', text: block.content },
scopeFor(block),
block.timestamp
)
)
}
}
}
return model
}
@@ -0,0 +1,486 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
type AgentNode,
applyTurnTerminal,
createTurnModel,
MAIN_SPAN,
reduceEvent,
type TextNode,
type ToolNode,
type TurnModel,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
interface Scope {
lane: 'subagent'
spanId?: string
parentSpanId?: string
parentToolCallId?: string
agentId?: string
}
function envelope(
seq: number,
type: string,
payload: Record<string, unknown>,
scope?: Scope
): PersistedStreamEventEnvelope {
return {
v: 1,
seq,
ts: new Date(seq).toISOString(),
stream: { streamId: 's1', cursor: String(seq) },
type,
payload,
...(scope ? { scope } : {}),
} as unknown as PersistedStreamEventEnvelope
}
function toolCall(seq: number, id: string, name: string, scope?: Scope) {
return envelope(seq, 'tool', { phase: 'call', toolCallId: id, toolName: name }, scope)
}
function toolResult(seq: number, id: string, success: boolean, status?: string, scope?: Scope) {
return envelope(
seq,
'tool',
{ phase: 'result', toolCallId: id, toolName: 'x', success, ...(status ? { status } : {}) },
scope
)
}
function spanStart(
seq: number,
spanId: string,
agent: string,
parentToolCallId?: string,
parentSpanId = MAIN_SPAN
) {
return envelope(
seq,
'span',
{
kind: 'subagent',
event: 'start',
agent,
data: parentToolCallId ? { tool_call_id: parentToolCallId } : {},
},
{
lane: 'subagent',
spanId,
parentSpanId,
...(parentToolCallId ? { parentToolCallId } : {}),
agentId: agent,
}
)
}
function spanEnd(
seq: number,
spanId: string,
agent: string,
opts?: { error?: string; pending?: boolean }
) {
return envelope(
seq,
'span',
{
kind: 'subagent',
event: 'end',
agent,
data: {
...(opts?.error ? { error: opts.error } : {}),
...(opts?.pending ? { pending: true } : {}),
},
},
{ lane: 'subagent', spanId, agentId: agent }
)
}
function textEvent(seq: number, channel: 'assistant' | 'thinking', text: string, scope?: Scope) {
return envelope(seq, 'text', { channel, text }, scope)
}
function complete(seq: number, status: 'complete' | 'cancelled' | 'error' = 'complete') {
return envelope(seq, 'complete', { status })
}
function apply(events: PersistedStreamEventEnvelope[], model = createTurnModel()): TurnModel {
for (const e of events) reduceEvent(model, e)
return model
}
function tool(model: TurnModel, id: string): ToolNode {
const node = model.nodes.get(id)
expect(node?.kind).toBe('tool')
return node as ToolNode
}
function agent(model: TurnModel, spanId: string): AgentNode {
const node = model.nodes.get(spanId)
expect(node?.kind).toBe('agent')
return node as AgentNode
}
describe('reduceEvent — tool lifecycle', () => {
it('runs a tool then settles it success on result', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true)])
expect(tool(m, 'tc-1').status).toBe('success')
expect(tool(m, 'tc-1').result?.success).toBe(true)
})
it('settles a tool error on a failed result', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', false)])
expect(tool(m, 'tc-1').status).toBe('error')
})
it('honors an explicit terminal status over the success boolean', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true, 'cancelled')])
expect(tool(m, 'tc-1').status).toBe('cancelled')
})
it('accumulates streaming args across deltas', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
argumentsDelta: '{"a":',
}),
envelope(3, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
argumentsDelta: '1}',
}),
])
expect(tool(m, 'tc-1').streamingArgs).toBe('{"a":1}')
expect(tool(m, 'tc-1').status).toBe('running')
})
it('clears streamingArgs once the result settles the tool', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
argumentsDelta: '{"operation":"create"',
}),
toolResult(3, 'tc-1', true),
])
expect(tool(m, 'tc-1').status).toBe('success')
expect(tool(m, 'tc-1').streamingArgs).toBeUndefined()
})
it('buffers a result that arrives before its call, then applies it', () => {
const m = apply([toolResult(1, 'tc-1', true), toolCall(2, 'tc-1', 'search')])
expect(tool(m, 'tc-1').status).toBe('success')
})
it('preserves result.error when a result is buffered before its call', () => {
const m = apply([
envelope(1, 'tool', {
phase: 'result',
toolCallId: 'tc-1',
toolName: 'search',
success: false,
error: 'boom',
}),
toolCall(2, 'tc-1', 'search'),
])
expect(tool(m, 'tc-1').status).toBe('error')
expect(tool(m, 'tc-1').result?.error).toBe('boom')
})
it('resolves output-based cancellation (user_cancelled) as cancelled, not error', () => {
const m = apply([
toolCall(1, 'tc-1', 'search'),
envelope(2, 'tool', {
phase: 'result',
toolCallId: 'tc-1',
toolName: 'search',
success: false,
output: { reason: 'user_cancelled' },
}),
])
expect(tool(m, 'tc-1').status).toBe('cancelled')
})
it('ignores preview phases (decoupled from tool status)', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
envelope(2, 'tool', {
previewPhase: 'file_preview_content',
toolCallId: 'tc-1',
toolName: 'workspace_file',
content: 'x',
contentMode: 'delta',
fileName: 'f',
previewVersion: 1,
}),
])
expect(tool(m, 'tc-1').status).toBe('running')
expect(m.order).toEqual(['tc-1'])
})
})
describe('reduceEvent — subagent lifecycle', () => {
it('opens an agent run on span start and settles it on span end', () => {
const m = apply([spanStart(1, 'S1', 'file', 'tc-file'), spanEnd(2, 'S1', 'file')])
expect(agent(m, 'S1').status).toBe('success')
expect(agent(m, 'S1').triggerToolCallId).toBe('tc-file')
expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN)
})
it('settles an agent error when span end carries an error', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
spanEnd(2, 'S1', 'file', { error: 'boom' }),
])
expect(agent(m, 'S1').status).toBe('error')
})
it('keeps an agent running on a pending-pause span end', () => {
const m = apply([
spanStart(1, 'S1', 'deploy', 'tc-deploy'),
spanEnd(2, 'S1', 'deploy', { pending: true }),
])
expect(agent(m, 'S1').status).toBe('running')
})
it('nests a child run under its parent by parentSpanId', () => {
const m = apply([
spanStart(1, 'S1', 'workflow', 'tc-wf'),
spanStart(2, 'S2', 'deploy', 'tc-deploy', 'S1'),
spanEnd(3, 'S2', 'deploy'),
spanEnd(4, 'S1', 'workflow'),
])
expect(agent(m, 'S2').parentSpanId).toBe('S1')
expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN)
expect(agent(m, 'S1').status).toBe('success')
expect(agent(m, 'S2').status).toBe('success')
})
it('keeps two parallel same-name runs independent (no agentId collision)', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-a'),
spanStart(2, 'S2', 'file', 'tc-b'),
toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }),
toolResult(5, 'wf-a', true),
spanEnd(6, 'S1', 'file'),
toolResult(7, 'wf-b', true),
spanEnd(8, 'S2', 'file'),
])
expect(agent(m, 'S1').triggerToolCallId).toBe('tc-a')
expect(agent(m, 'S2').triggerToolCallId).toBe('tc-b')
expect(tool(m, 'wf-a').spanId).toBe('S1')
expect(tool(m, 'wf-b').spanId).toBe('S2')
expect(agent(m, 'S1').status).toBe('success')
expect(agent(m, 'S2').status).toBe('success')
})
})
describe('reduceEvent — text segmentation', () => {
it('records wall-clock start/end for a thinking segment from wire ts', () => {
// envelope() stamps ts = new Date(seq).toISOString(), so tsMs === seq here.
const m = apply([
textEvent(1, 'thinking', 'pondering'),
textEvent(2, 'assistant', 'the answer'),
])
const thinking = [...m.nodes.values()].find(
(n) => n.kind === 'text' && n.channel === 'thinking'
) as TextNode
expect(thinking.startedAtMs).toBe(1)
// The answer starting closes the thinking segment, bounding its duration.
expect(thinking.endedAtMs).toBe(2)
})
it('merges contiguous deltas and splits across a tool boundary', () => {
const m = apply([
textEvent(1, 'assistant', 'Hello '),
textEvent(2, 'assistant', 'world'),
toolCall(3, 'tc-1', 'search'),
toolResult(4, 'tc-1', true),
textEvent(5, 'assistant', 'after'),
])
const texts = m.order.map((id) => m.nodes.get(id)).filter((n) => n?.kind === 'text')
expect(texts.map((t) => (t as { text: string }).text)).toEqual(['Hello world', 'after'])
})
})
describe('reduceEvent — idempotency', () => {
it('is a no-op for an already-applied seq (reconnect replay over a populated model)', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true)])
const before = JSON.stringify([...m.nodes])
reduceEvent(m, toolCall(1, 'tc-1', 'search'))
reduceEvent(m, toolResult(2, 'tc-1', true))
expect(JSON.stringify([...m.nodes])).toBe(before)
expect(m.order).toEqual(['tc-1'])
})
it('rebuilds the identical model when replayed into a fresh model', () => {
const events = [
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
toolResult(3, 'wf', true),
spanEnd(4, 'S1', 'file'),
complete(5),
]
const live = apply(events)
const replayed = apply(events, createTurnModel())
expect([...replayed.nodes]).toEqual([...live.nodes])
expect(replayed.order).toEqual(live.order)
expect(replayed.status).toBe(live.status)
})
})
describe('reduceEvent — edit_content row merge', () => {
it('folds an edit_content write into its span workspace_file row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
toolResult(3, 'wf-1', true, undefined, sub),
toolCall(4, 'ec-1', 'edit_content', sub),
])
// No separate edit_content node; the workspace_file row reopened for the edit.
expect(m.nodes.has('ec-1')).toBe(false)
expect(tool(m, 'wf-1').status).toBe('running')
expect(m.toolAlias.get('ec-1')).toBe('wf-1')
})
it('settles the merged row on the edit_content result', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
toolCall(3, 'ec-1', 'edit_content', sub),
toolResult(4, 'ec-1', true, undefined, sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(m.nodes.has('ec-1')).toBe(false)
})
it('folds an edit_content result that raced ahead of its call into the merged row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
// Result for edit_content arrives BEFORE its call (buffered under ec-1)...
toolResult(3, 'ec-1', true, undefined, sub),
// ...then the call lands and aliases ec-1 -> wf-1, draining the buffer.
toolCall(4, 'ec-1', 'edit_content', sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(tool(m, 'wf-1').result?.success).toBe(true)
expect(m.bufferedResults.has('ec-1')).toBe(false)
})
it('finalizes a stale running section row when the next section opens', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
// Section 1: the workspace_file row is reopened by its edit_content, but the
// edit's closing result is reordered/dropped — wf-1 is left running.
toolCall(2, 'wf-1', 'workspace_file', sub),
toolResult(3, 'wf-1', true, undefined, sub),
toolCall(4, 'ec-1', 'edit_content', sub),
// Section 2 opens before section 1's edit result lands.
toolCall(5, 'wf-2', 'workspace_file', sub),
])
// The previous section settles instead of spinning until the turn terminal...
expect(tool(m, 'wf-1').status).toBe('success')
// ...and the new section's row is the live write.
expect(tool(m, 'wf-2').status).toBe('running')
})
})
describe('reduceEvent — error tag + compaction coverage', () => {
it('appends an inline mothership-error tag to the scoped lane text', () => {
const m = apply([
textEvent(1, 'assistant', 'Working'),
envelope(2, 'error', { message: 'boom', code: 'E1', provider: 'openai' }),
])
const text = [...m.nodes.values()].find((n) => n.kind === 'text') as { text: string }
expect(text.text).toContain('<mothership-error>')
expect(text.text).toContain('boom')
expect(text.text).toContain('E1')
})
it('does not duplicate an identical error tag', () => {
const m = apply([
textEvent(1, 'assistant', 'Working'),
envelope(2, 'error', { message: 'boom' }),
envelope(3, 'error', { message: 'boom' }),
])
const text = [...m.nodes.values()].find((n) => n.kind === 'text') as { text: string }
const occurrences = text.text.split('<mothership-error>').length - 1
expect(occurrences).toBe(1)
})
it('opens and closes a compaction node with titles', () => {
const m = apply([
envelope(1, 'run', { kind: 'compaction_start' }),
envelope(2, 'run', { kind: 'compaction_done' }),
])
const compaction = [...m.nodes.values()].find(
(n) => n.kind === 'tool' && n.name === 'context_compaction'
) as ToolNode
expect(compaction.status).toBe('success')
expect(compaction.uiTitle).toBe('Compacted context')
})
})
describe('turn-terminal propagation', () => {
it('settles stragglers as success on a clean complete (never interrupted)', () => {
const m = apply([
toolCall(1, 'tc-1', 'search'),
spanStart(2, 'S1', 'file', 'tc-file'),
complete(3, 'complete'),
])
expect(m.status).toBe('complete')
expect(tool(m, 'tc-1').status).toBe('success')
expect(agent(m, 'S1').status).toBe('success')
for (const node of m.nodes.values()) {
expect(node.kind === 'text' || node.status).not.toBe('interrupted')
}
})
it('closes a straggler subagent lane (sets endSeq) so a model-driven terminal resolves the group', () => {
// A file subagent opened but no span end arrived (mid-stream error/disconnect).
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
])
expect(agent(m, 'S1').endSeq).toBeUndefined()
applyTurnTerminal(m, 'error')
expect(agent(m, 'S1').status).toBe('error')
// endSeq must be stamped so the serializer emits subagent_end and the lane's
// delegating spinner resolves instead of spinning forever.
expect(agent(m, 'S1').endSeq).toBeDefined()
})
it('settles open nodes cancelled on a stop', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), complete(2, 'cancelled')])
expect(m.status).toBe('cancelled')
expect(tool(m, 'tc-1').status).toBe('cancelled')
})
it('settles open nodes error on an errored turn', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), complete(2, 'error')])
expect(m.status).toBe('error')
expect(tool(m, 'tc-1').status).toBe('error')
})
it('never reopens an already-terminal node', () => {
const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', false)])
applyTurnTerminal(m, 'complete')
expect(tool(m, 'tc-1').status).toBe('error')
})
})
@@ -0,0 +1,653 @@
import { isRecordLike as isRecord } from '@sim/utils/object'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1RunKind,
MothershipStreamV1SpanLifecycleEvent,
MothershipStreamV1SpanPayloadKind,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
/**
* The single deterministic model of one assistant turn, derived purely from the
* Go wire stream. Every tool and subagent is a {@link LifecycleNode} with one
* explicit terminal source, so rendering reads node status instead of inferring
* it from transport, preview sessions, or a turn-complete sweep. Parallel
* subagents are independent span lanes; nested subagents nest by `parentSpanId`.
*/
/** The root span lane id Go stamps on main-agent (non-subagent) events. */
export const MAIN_SPAN = 'main'
/**
* Terminal-bearing status for a single node. `running` is the only
* non-terminal value; everything else is read from an explicit wire terminal
* (tool `result`, span `end`) or propagated from the turn terminal.
*/
export type NodeStatus = 'running' | 'success' | 'error' | 'cancelled' | 'skipped' | 'rejected'
/** Turn-level status. Terminal values come from the wire `complete`/`error`. */
export type TurnStatus = 'streaming' | 'complete' | 'error' | 'cancelled'
export type TextChannel = 'assistant' | 'thinking'
interface NodeBase {
/** Stable node id. Tools use `toolCallId`; agents use `spanId`; text/synthetic use a derived id. */
id: string
/** The span lane this node belongs to (`MAIN_SPAN` for the main agent). */
spanId: string
/** Arrival order key (wire `seq`), monotonic within a turn. */
seq: number
/**
* Wall-clock (wire `ts`) the node opened. Serialized as the block `timestamp`
* so it always means epoch-ms (never the wire seq), driving duration UI and
* surviving the reconnect round-trip. Absent only when `ts` was unavailable.
*/
startedAtMs?: number
}
export interface ToolNode extends NodeBase {
kind: 'tool'
name: string
status: NodeStatus
args?: Record<string, unknown>
streamingArgs?: string
uiTitle?: string
/** Per-call `ui.hidden` flag — the node is tracked for side effects but not rendered. */
hidden?: boolean
result?: { success: boolean; output?: unknown; error?: string }
}
export interface AgentNode extends NodeBase {
kind: 'agent'
/** Span lane of the run that invoked this one (`MAIN_SPAN` for a direct child). */
parentSpanId: string
/** Display id (e.g. `file`, `workflow`) — never a routing key (collides across siblings). */
agentId: string
/** The outer delegation tool_use that triggered this run; links the trigger tool node. */
triggerToolCallId?: string
status: NodeStatus
/** Wire seq at which the run terminated (span end), for ordering the close marker. */
endSeq?: number
}
export interface TextNode extends NodeBase {
kind: 'text'
channel: TextChannel
text: string
/** Wall-clock (wire `ts`) the segment was superseded by the next lane content. */
endedAtMs?: number
}
export type LifecycleNode = ToolNode | AgentNode | TextNode
export interface TurnModel {
status: TurnStatus
/** All nodes by id. */
nodes: Map<string, LifecycleNode>
/** Node ids in arrival order — the projection orders within a lane by this. */
order: string[]
/** spanId -> agent node id (always equal to spanId). */
agentBySpanId: Map<string, string>
/** `${spanId}::${channel}` -> currently-open text node id (cleared on a lane break). */
openTextByKey: Map<string, string>
/**
* Results that arrived before their tool `call` (out-of-order), keyed by
* toolCallId. Raw `status`/`output` are kept so the outcome (incl. output-based
* cancellation) resolves identically to the in-order path when drained.
*/
bufferedResults: Map<
string,
{ success: boolean; output?: unknown; status?: unknown; error?: string }
>
/**
* Maps a tool call id to another tool node it folds into. Used for the
* `edit_content` -> `workspace_file` row merge so the write streams into the
* single "writing" row rather than a second row.
*/
toolAlias: Map<string, string>
/** Highest applied wire seq; events at or below are no-ops (cursor-idempotent replay). */
lastSeq: number
}
export function createTurnModel(): TurnModel {
return {
status: 'streaming',
nodes: new Map(),
order: [],
agentBySpanId: new Map(),
openTextByKey: new Map(),
bufferedResults: new Map(),
toolAlias: new Map(),
lastSeq: 0,
}
}
const WORKSPACE_FILE_TOOL = 'workspace_file'
const EDIT_CONTENT_TOOL = 'edit_content'
/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */
export function resolveToolId(model: TurnModel, id: string): string {
return model.toolAlias.get(id) ?? id
}
/**
* Finds the most recent `workspace_file` tool node in a span so an `edit_content`
* write folds into it (the single "writing" row). Co-location in the file
* subagent's span is the link — no coupling to preview phases. The caller
* reopens whatever this returns, including an already-settled row (an edit after
* a completed write is the same file operation continuing), which is the
* intended single-row behavior, not the old preview-gated parent reuse.
*/
function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode | undefined {
for (let i = model.order.length - 1; i >= 0; i--) {
const node = model.nodes.get(model.order[i])
if (node?.kind === 'tool' && node.spanId === spanId && node.name === WORKSPACE_FILE_TOOL) {
return node
}
}
return undefined
}
/**
* The file agent writes a file as strictly sequential `workspace_file` +
* `edit_content` section pairs, waiting for each to finish before the next. So
* when a new section's `workspace_file` opens, any earlier `workspace_file` row
* still `running` in the same span is a completed section whose closing
* `edit_content` result was reordered or dropped — finalize it as success so its
* "writing" spinner resolves when the next section starts, instead of lingering
* until the turn-terminal sweep. A no-op on the happy path (prior rows already
* settled on their own result).
*/
function finalizeStaleWorkspaceFiles(model: TurnModel, spanId: string): void {
for (const id of model.order) {
const node = model.nodes.get(id)
if (
node?.kind === 'tool' &&
node.spanId === spanId &&
node.name === WORKSPACE_FILE_TOOL &&
node.status === 'running'
) {
node.status = 'success'
node.streamingArgs = undefined
}
}
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
/**
* Reads a wire event payload as a generic record. The payload is a wide
* discriminated union; the reducer accesses fields uniformly, so this narrows
* through the `unknown`-typed {@link isRecord} guard rather than a double cast.
*/
function payloadRecord(payload: unknown): Record<string, unknown> {
return isRecord(payload) ? payload : {}
}
/** Parses a wire `ts` to epoch ms, or undefined when absent/unparseable. */
function tsToMs(ts: unknown): number | undefined {
if (typeof ts !== 'string' || ts === '') return undefined
const ms = Date.parse(ts)
return Number.isFinite(ms) ? ms : undefined
}
const TERMINAL_NODE_STATUSES = new Set<NodeStatus>([
'success',
'error',
'cancelled',
'skipped',
'rejected',
])
export function isNodeTerminal(status: NodeStatus): boolean {
return TERMINAL_NODE_STATUSES.has(status)
}
/** Maps the wire turn-completion status to the status propagated to open nodes. */
function turnTerminalNodeStatus(turn: Exclude<TurnStatus, 'streaming'>): NodeStatus {
if (turn === 'cancelled') return 'cancelled'
if (turn === 'error') return 'error'
return 'success'
}
/**
* Builds the inline `<mothership-error>` tag rendered for a stream error. Kept
* byte-identical to the prior `buildInlineErrorTag` so the error special-tag
* parser renders it the same way.
*/
function buildMothershipErrorTag(payload: Record<string, unknown>): string {
const message =
asString(payload.displayMessage) ??
asString(payload.message) ??
asString(payload.error) ??
'An unexpected error occurred'
const provider = asString(payload.provider)
const code = asString(payload.code)
return `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
/** Closes a span's open text segment for `channel`, stamping its end time. */
function closeOpenText(
model: TurnModel,
spanId: string,
channel: TextChannel,
atMs?: number
): void {
const key = `${spanId}::${channel}`
const nodeId = model.openTextByKey.get(key)
if (!nodeId) return
if (atMs !== undefined) {
const node = model.nodes.get(nodeId)
if (node?.kind === 'text' && node.endedAtMs === undefined) node.endedAtMs = atMs
}
model.openTextByKey.delete(key)
}
/** Drops any open text segments in a lane so the next text starts a fresh node. */
function breakLane(model: TurnModel, spanId: string, atMs?: number): void {
closeOpenText(model, spanId, 'assistant', atMs)
closeOpenText(model, spanId, 'thinking', atMs)
}
function appendText(
model: TurnModel,
spanId: string,
channel: TextChannel,
text: string,
seq: number,
atMs?: number
): void {
if (!text) return
const key = `${spanId}::${channel}`
const openId = model.openTextByKey.get(key)
const open = openId ? model.nodes.get(openId) : undefined
if (open && open.kind === 'text') {
open.text += text
return
}
// A new segment supersedes the other channel's open text (e.g. the answer
// starts after thinking), which bounds the thinking segment's duration.
closeOpenText(model, spanId, channel === 'thinking' ? 'assistant' : 'thinking', atMs)
const node: TextNode = {
kind: 'text',
id: `text:${seq}`,
spanId,
channel,
text,
seq,
...(atMs !== undefined ? { startedAtMs: atMs } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
model.openTextByKey.set(key, node.id)
}
/**
* Applies a result that raced ahead of its tool `call` (buffered under `fromId`)
* onto `node`, then clears the buffer. Used by the normal call path and by the
* edit_content -> workspace_file merge, where the buffer is keyed by the
* edit_content id but folds into the workspace_file row.
*/
function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void {
const buffered = model.bufferedResults.get(fromId)
if (!buffered) return
model.bufferedResults.delete(fromId)
node.status = resolveStreamToolOutcome({
status: asString(buffered.status),
success: buffered.success,
output: buffered.output,
})
node.result = {
success: buffered.success,
output: buffered.output,
...(buffered.error ? { error: buffered.error } : {}),
}
node.streamingArgs = undefined
}
function upsertToolNode(
model: TurnModel,
id: string,
spanId: string,
name: string,
seq: number,
atMs?: number
): ToolNode {
const existing = model.nodes.get(id)
if (existing && existing.kind === 'tool') {
if (name && !existing.name) existing.name = name
return existing
}
const node: ToolNode = {
kind: 'tool',
id,
spanId,
name,
status: 'running',
seq,
...(atMs !== undefined ? { startedAtMs: atMs } : {}),
}
model.nodes.set(id, node)
model.order.push(id)
// A tool starting (or any structural event) closes the current text run.
breakLane(model, spanId, atMs)
drainBufferedResult(model, id, node)
return node
}
function applyToolResult(
model: TurnModel,
id: string,
success: boolean,
status: unknown,
output: unknown,
error: string | undefined
): void {
const existing = model.nodes.get(id)
if (existing && existing.kind === 'tool') {
existing.status = resolveStreamToolOutcome({ status: asString(status), success, output })
existing.result = { success, output, ...(error ? { error } : {}) }
// The args have fully resolved; drop the partial stream so the title and
// any re-serialization read the final args, not truncated streaming JSON.
existing.streamingArgs = undefined
return
}
// Result before call: buffer raw fields until the call materializes the node;
// the outcome (incl. output-based cancellation) is resolved on drain.
model.bufferedResults.set(id, { success, output, status, ...(error ? { error } : {}) })
}
/**
* Materializes a subagent lane on first reference. Subagent-scoped content
* (text/thinking/tool) can be reduced before its `subagent_start` under heavy
* parallel bursts (many subagents streaming into one ordered channel); without
* the owning `AgentNode` the serializer can't attribute the content, so it leaks
* into the main lane and the subagent's thinking is dropped until the start
* lands. The wire scope already carries the lane identity (Go tags every
* forwarded subagent event with its agent id/span), so the lane is rebuilt
* deterministically from the content event itself — the symmetric counterpart to
* buffering a result before its call. The later `subagent_start` finds this node
* and no-ops.
*/
function ensureSubagentLane(
model: TurnModel,
spanId: string,
scope: { agentId?: string; parentSpanId?: string; parentToolCallId?: string } | undefined,
seq: number,
atMs?: number
): void {
if (spanId === MAIN_SPAN || model.agentBySpanId.has(spanId)) return
const node: AgentNode = {
kind: 'agent',
id: spanId,
spanId,
parentSpanId: scope?.parentSpanId ?? MAIN_SPAN,
agentId: scope?.agentId ?? '',
status: 'running',
seq,
...(atMs !== undefined ? { startedAtMs: atMs } : {}),
...(scope?.parentToolCallId ? { triggerToolCallId: scope.parentToolCallId } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
model.agentBySpanId.set(spanId, node.id)
}
/**
* Folds one wire envelope into the model. Pure accumulator: it mutates and
* returns the same `model` (the streaming hot path keeps one model per turn).
* `seq` is the monotonic wire cursor — the contract guarantees it is always a
* finite number — so it is the sole ordering and idempotency key: an event at
* or below the applied high-water mark is a replay and no-ops (reconnect replay
* over a populated model is a no-op; replay into a fresh model rebuilds the
* identical tree).
*/
export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnvelope): TurnModel {
const seq = envelope.seq
if (seq <= model.lastSeq) return model
model.lastSeq = seq
const tsMs = tsToMs(envelope.ts)
const scope = envelope.scope
const spanId = scope?.spanId ?? MAIN_SPAN
switch (envelope.type) {
case MothershipStreamV1EventType.text: {
const payload = envelope.payload
ensureSubagentLane(model, spanId, scope, seq, tsMs)
appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs)
break
}
case MothershipStreamV1EventType.tool: {
const payload = payloadRecord(envelope.payload)
// Preview phases are a separate panel concern (decoupled from tool status).
if ('previewPhase' in payload) break
const rawToolCallId = asString(payload.toolCallId)
if (!rawToolCallId) break
const toolName = asString(payload.toolName) ?? ''
ensureSubagentLane(model, spanId, scope, seq, tsMs)
const phase = payload.phase
if (phase === MothershipStreamV1ToolPhase.call) {
// edit_content folds into its span's workspace_file row (the write
// continues in the single "writing" row), reopening it for the edit.
if (toolName === EDIT_CONTENT_TOOL) {
// A re-emitted edit_content call (same tool call id — duplicate/replay)
// must keep its ORIGINAL target row. Re-running the span lookup can
// return a newer workspace_file, and folding into that would leave the
// first (already reopened) row running with no result ever closing it —
// a spinner stuck until the turn-terminal sweep. So once aliased, reuse.
const aliasedId = model.toolAlias.get(rawToolCallId)
const aliasedParent = aliasedId ? model.nodes.get(aliasedId) : undefined
const parent =
aliasedParent?.kind === 'tool'
? aliasedParent
: findWorkspaceFileNodeInSpan(model, spanId)
if (parent) {
model.toolAlias.set(rawToolCallId, parent.id)
parent.status = 'running'
parent.result = undefined
// A result that raced ahead of this call was buffered under the
// edit_content id; fold it into the reopened workspace_file row.
drainBufferedResult(model, rawToolCallId, parent)
break
}
}
// A new file section opening settles any earlier still-running section row
// in this span (the file agent writes sections sequentially).
if (toolName === WORKSPACE_FILE_TOOL && !model.nodes.has(rawToolCallId)) {
finalizeStaleWorkspaceFiles(model, spanId)
}
const node = upsertToolNode(
model,
resolveToolId(model, rawToolCallId),
spanId,
toolName,
seq,
tsMs
)
if (isRecord(payload.arguments)) node.args = payload.arguments
// Tool-call titles are derived from the tool name (+args) at serialize
// time; the stream only carries behavioral flags now.
const ui = isRecord(payload.ui) ? payload.ui : undefined
if (ui?.hidden === true) node.hidden = true
} else if (phase === MothershipStreamV1ToolPhase.args_delta) {
const node = upsertToolNode(
model,
resolveToolId(model, rawToolCallId),
spanId,
toolName,
seq,
tsMs
)
const delta = asString(payload.argumentsDelta)
if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta
} else if (phase === MothershipStreamV1ToolPhase.result) {
applyToolResult(
model,
resolveToolId(model, rawToolCallId),
payload.success === true,
payload.status,
payload.output,
asString(payload.error)
)
}
break
}
case MothershipStreamV1EventType.span: {
const payload = envelope.payload
if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) break
const data = isRecord(payload.data) ? payload.data : undefined
const triggerToolCallId =
scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId)
const agentId = asString(payload.agent) ?? scope?.agentId ?? ''
const resolvedSpanId =
scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`)
const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN
if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) {
breakLane(model, parentSpanId, tsMs)
const existingId = model.agentBySpanId.get(resolvedSpanId)
if (existingId && model.nodes.has(existingId)) break
const node: AgentNode = {
kind: 'agent',
id: resolvedSpanId,
spanId: resolvedSpanId,
parentSpanId,
agentId,
status: 'running',
seq: seq,
...(tsMs !== undefined ? { startedAtMs: tsMs } : {}),
...(triggerToolCallId ? { triggerToolCallId } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
model.agentBySpanId.set(resolvedSpanId, node.id)
} else if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) {
// A pending pause is not a terminal — the run resumes later.
if (data?.pending === true) break
breakLane(model, resolvedSpanId, tsMs)
const node = model.nodes.get(resolvedSpanId)
if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) {
node.status = data && asString(data.error) ? 'error' : 'success'
node.endSeq = seq
}
}
break
}
case MothershipStreamV1EventType.run: {
const payload = payloadRecord(envelope.payload)
const kind = payload.kind
if (kind === MothershipStreamV1RunKind.compaction_start) {
const node = upsertToolNode(
model,
`compaction:${seq}`,
spanId,
'context_compaction',
seq,
tsMs
)
node.uiTitle = 'Compacting context...'
} else if (kind === MothershipStreamV1RunKind.compaction_done) {
let finalized = false
for (let i = model.order.length - 1; i >= 0; i--) {
const node = model.nodes.get(model.order[i])
if (
node?.kind === 'tool' &&
node.name === 'context_compaction' &&
node.status === 'running'
) {
node.status = 'success'
node.uiTitle = 'Compacted context'
finalized = true
break
}
}
if (!finalized) {
const node = upsertToolNode(
model,
`compaction:${seq}`,
spanId,
'context_compaction',
seq,
tsMs
)
node.status = 'success'
node.uiTitle = 'Compacted context'
}
}
break
}
case MothershipStreamV1EventType.error: {
// The error tag is content (rendered inline by the error special-tag); turn
// termination on error is applied by the stream loop's terminal handling,
// not here, so a non-fatal mid-stream error event never settles the turn.
const tag = buildMothershipErrorTag(payloadRecord(envelope.payload))
const key = `${spanId}::assistant`
const openId = model.openTextByKey.get(key)
const open = openId ? model.nodes.get(openId) : undefined
if (open && open.kind === 'text') {
if (!open.text.includes(tag)) {
const prefix = open.text.length > 0 && !open.text.endsWith('\n') ? '\n' : ''
open.text += prefix + tag
}
} else {
appendText(model, spanId, 'assistant', tag, seq, tsMs)
}
break
}
case MothershipStreamV1EventType.complete: {
const payload = payloadRecord(envelope.payload)
// An async pause is not a turn terminal — the paused tools/subagents
// legitimately stay open until a later resume leg completes them.
const response = isRecord(payload.response) ? payload.response : undefined
if (response && 'async_pause' in response) break
const status = payload.status
if (status === MothershipStreamV1CompletionStatus.cancelled) {
applyTurnTerminal(model, 'cancelled')
} else if (status === MothershipStreamV1CompletionStatus.error) {
applyTurnTerminal(model, 'error')
} else {
applyTurnTerminal(model, 'complete')
}
break
}
default:
break
}
return model
}
/**
* Sets the turn terminal and propagates it to every still-running node. This is
* the deterministic replacement for the old `interrupted` sweep: a clean
* `complete` settles stragglers as `success` (the turn succeeded), a stop as
* `cancelled`, an error as `error`. With explicit tool/span terminals there are
* normally no stragglers, so this is the abort/disconnect safety net, not a
* routine path.
*/
export function applyTurnTerminal(model: TurnModel, turn: Exclude<TurnStatus, 'streaming'>): void {
model.status = turn
const nodeStatus = turnTerminalNodeStatus(turn)
for (const id of model.order) {
const node = model.nodes.get(id)
if (!node || node.kind === 'text') continue
if (node.status === 'running') {
node.status = nodeStatus
// Close a straggler subagent lane (no explicit span end) so the serializer
// emits its `subagent_end` and the group resolves — otherwise the
// delegating spinner spins forever after a model-driven terminal
// (error/disconnect), the bug the snapshot path closes via `endedAt`.
if (node.kind === 'agent' && node.endSeq === undefined) {
node.endSeq = model.lastSeq
}
}
}
}
@@ -0,0 +1,234 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import {
MothershipStreamV1EventType,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
import {
getReplayCompletedWorkflowToolCallIds,
reconcileLiveAssistantTurn,
selectReconnectReplayState,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
vi.mock('next/navigation', () => ({
usePathname: () => '/workspace/workspace-1/home',
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
}),
}))
function userMessage(id: string): PersistedMessage {
return {
id,
role: 'user',
content: 'Question',
timestamp: '2026-05-08T00:00:00.000Z',
}
}
function assistantMessage(id: string, content: string): PersistedMessage {
return {
id,
role: 'assistant',
content,
timestamp: '2026-05-08T00:00:01.000Z',
}
}
function toolBatchEvent(
eventId: number,
toolCallId: string,
toolName: string,
phase: MothershipStreamV1ToolPhase
): StreamBatchEvent {
return {
eventId,
streamId: 'stream-1',
event: {
v: 1,
seq: eventId,
ts: '2026-05-08T00:00:00.000Z',
type: MothershipStreamV1EventType.tool,
stream: { streamId: 'stream-1' },
payload: {
phase,
toolCallId,
toolName,
},
},
} as StreamBatchEvent
}
describe('reconcileLiveAssistantTurn', () => {
it('replaces the live assistant for the active stream owner', () => {
const liveAssistant = assistantMessage('live-assistant:stream-1', 'updated')
const messages = [userMessage('stream-1'), assistantMessage('live-assistant:stream-1', 'old')]
const result = reconcileLiveAssistantTurn({
messages,
streamId: 'stream-1',
liveAssistant,
activeStreamId: 'stream-1',
})
expect(result).toEqual([userMessage('stream-1'), liveAssistant])
})
it('replaces the generated assistant after the owner while the stream is active', () => {
const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content')
const result = reconcileLiveAssistantTurn({
messages: [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')],
streamId: 'stream-1',
liveAssistant,
activeStreamId: 'stream-1',
})
expect(result).toEqual([userMessage('stream-1'), liveAssistant])
})
it('leaves a terminal persisted assistant alone when the stream is no longer active', () => {
const messages = [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')]
const result = reconcileLiveAssistantTurn({
messages,
streamId: 'stream-1',
liveAssistant: assistantMessage('live-assistant:stream-1', 'stale live content'),
activeStreamId: null,
})
expect(result).toBe(messages)
})
it('removes stale live assistant duplicates when a terminal persisted assistant exists', () => {
const finalAssistant = assistantMessage('final-1', 'persisted content')
const staleLiveAssistant = assistantMessage('live-assistant:stream-1', 'stale live content')
const result = reconcileLiveAssistantTurn({
messages: [
userMessage('stream-1'),
finalAssistant,
userMessage('next-user'),
staleLiveAssistant,
],
streamId: 'stream-1',
liveAssistant: staleLiveAssistant,
activeStreamId: null,
})
expect(result).toEqual([userMessage('stream-1'), finalAssistant, userMessage('next-user')])
})
it('inserts the live assistant immediately after its owner', () => {
const nextUser = userMessage('next-user')
const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content')
const result = reconcileLiveAssistantTurn({
messages: [userMessage('stream-1'), nextUser],
streamId: 'stream-1',
liveAssistant,
activeStreamId: 'stream-1',
})
expect(result).toEqual([userMessage('stream-1'), liveAssistant, nextUser])
})
})
describe('selectReconnectReplayState', () => {
it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => {
const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' }
const result = selectReconnectReplayState({
afterCursor: '4',
cachedLiveAssistant: {
content: 'Hello world',
contentBlocks: [cachedBlock],
},
currentContent: 'Hello',
currentBlocks: [],
})
expect(result).toEqual({
afterCursor: '4',
content: 'Hello world',
contentBlocks: [cachedBlock],
preserveExistingState: true,
source: 'cache',
})
})
it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
cachedLiveAssistant: null,
currentContent: '',
currentBlocks: [],
})
expect(result).toEqual({
afterCursor: '0',
content: '',
contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
})
it('resets when cached live content diverges from the local prefix', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
cachedLiveAssistant: {
content: 'Goodbye world',
contentBlocks: [{ type: 'text', content: 'Goodbye world' }],
},
currentContent: 'Hello',
currentBlocks: [{ type: 'text', content: 'Hello' }],
})
expect(result).toEqual({
afterCursor: '0',
content: '',
contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
})
it('resets current state for cursor zero replay', () => {
const currentBlock: ContentBlock = { type: 'text', content: 'Hello' }
const result = selectReconnectReplayState({
afterCursor: '0',
cachedLiveAssistant: null,
currentContent: 'Hello',
currentBlocks: [currentBlock],
})
expect(result).toEqual({
afterCursor: '0',
content: '',
contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
})
})
describe('getReplayCompletedWorkflowToolCallIds', () => {
it('suppresses only workflow tool starts that already have results in the replay batch', () => {
const result = getReplayCompletedWorkflowToolCallIds([
toolBatchEvent(1, 'workflow-active', 'run_workflow', MothershipStreamV1ToolPhase.call),
toolBatchEvent(2, 'search-complete', 'tool_search', MothershipStreamV1ToolPhase.result),
toolBatchEvent(3, 'workflow-complete', 'run_workflow', MothershipStreamV1ToolPhase.result),
])
expect(result).toEqual(new Set(['workflow-complete']))
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef } from 'react'
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
/**
* Hook for managing resize of the MothershipView resource panel.
*
* Uses imperative DOM manipulation (zero React re-renders during drag) with
* Pointer Events + setPointerCapture for unified mouse/touch/stylus support.
* Attach `mothershipRef` to the MothershipView root div and bind
* `handleResizePointerDown` to the drag handle's onPointerDown.
* Call `clearWidth` when the panel collapses so the CSS class retakes control.
*/
export function useMothershipResize() {
const mothershipRef = useRef<HTMLDivElement | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const handleResizePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
const el = mothershipRef.current
if (!el) return
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
// Pin to current rendered width so drag starts from the visual position
el.style.width = `${el.getBoundingClientRect().width}px`
// Disable CSS transition to prevent animation lag during drag
const prevTransition = el.style.transition
el.style.transition = 'none'
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
// AbortController removes all listeners at once on cleanup/cancel/unmount
const ac = new AbortController()
const { signal } = ac
const cleanup = () => {
ac.abort()
el.style.transition = prevTransition
document.body.style.cursor = ''
document.body.style.userSelect = ''
cleanupRef.current = null
}
cleanupRef.current = cleanup
handle.addEventListener(
'pointermove',
(moveEvent: PointerEvent) => {
const newWidth = window.innerWidth - moveEvent.clientX
const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE
el.style.width = `${Math.min(Math.max(newWidth, MOTHERSHIP_WIDTH.MIN), maxWidth)}px`
},
{ signal }
)
handle.addEventListener(
'pointerup',
(upEvent: PointerEvent) => {
handle.releasePointerCapture(upEvent.pointerId)
cleanup()
},
{ signal }
)
// Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.)
// Without this, body cursor/userSelect and transition would be permanently stuck
handle.addEventListener('pointercancel', cleanup, { signal })
}, [])
// Tear down any active drag if the component unmounts mid-drag
useEffect(() => {
return () => {
cleanupRef.current?.()
}
}, [])
// Re-clamp panel width when the viewport is resized (inline px width can exceed max after narrowing)
useEffect(() => {
const handleWindowResize = () => {
const el = mothershipRef.current
if (!el || !el.style.width) return
const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE
const current = el.getBoundingClientRect().width
if (current > maxWidth) {
el.style.width = `${maxWidth}px`
}
}
window.addEventListener('resize', handleWindowResize)
return () => window.removeEventListener('resize', handleWindowResize)
}, [])
/** Remove inline width so the collapse CSS class retakes control */
const clearWidth = useCallback(() => {
mothershipRef.current?.style.removeProperty('width')
}, [])
return { mothershipRef, handleResizePointerDown, clearWidth }
}