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
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:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getMothershipUseChatOptions,
|
||||
getWorkflowCopilotUseChatOptions,
|
||||
useChat,
|
||||
} from './use-chat'
|
||||
export { useMothershipResize } from './use-mothership-resize'
|
||||
+137
@@ -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'
|
||||
+412
@@ -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,
|
||||
}
|
||||
}
|
||||
+567
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user