mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(mothership): misc ui bugs (#4528)
* fix(vfs); compiled check tool call natural language tool desc * fix few more ui/ux bugs
This commit is contained in:
+2
@@ -89,6 +89,7 @@ export const PlusMenuDropdown = React.memo(
|
||||
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
|
||||
)
|
||||
}, [isMention, mentionQuery, search, availableResources])
|
||||
const isRootMenu = !isMention && filteredItems === null
|
||||
|
||||
const filteredItemsRef = useRef(filteredItems)
|
||||
filteredItemsRef.current = filteredItems
|
||||
@@ -248,6 +249,7 @@ export const PlusMenuDropdown = React.memo(
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden',
|
||||
isRootMenu && 'max-h-none',
|
||||
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
|
||||
// to its content via the emcn DropdownMenuContent default max-w.
|
||||
// Mention mode renders resource names directly, so widen for breathing room.
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -1140,6 +1140,167 @@ function isAlreadyProcessedStreamCursor(
|
||||
)
|
||||
}
|
||||
|
||||
function isZeroStreamCursor(cursor: string): boolean {
|
||||
const sequence = Number(cursor)
|
||||
return Number.isFinite(sequence) && sequence <= 0
|
||||
}
|
||||
|
||||
function isPersistedAssistantMessage(message: PersistedMessage, liveAssistantId: string): boolean {
|
||||
return (
|
||||
message.role === 'assistant' &&
|
||||
message.id !== liveAssistantId &&
|
||||
!message.id.startsWith('live-assistant:')
|
||||
)
|
||||
}
|
||||
|
||||
function findStreamOwnerIndex(messages: PersistedMessage[], streamId: string): number {
|
||||
return messages.findIndex((message) => message.role === 'user' && message.id === streamId)
|
||||
}
|
||||
|
||||
function findAssistantAfterOwner(messages: PersistedMessage[], ownerIndex: number): number {
|
||||
for (let index = ownerIndex + 1; index < messages.length; index++) {
|
||||
const message = messages[index]
|
||||
if (message.role === 'user') return -1
|
||||
if (message.role === 'assistant') return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function hasTerminalPersistedAssistantForStream(
|
||||
messages: PersistedMessage[],
|
||||
streamId: string,
|
||||
liveAssistantId: string
|
||||
): boolean {
|
||||
const ownerIndex = findStreamOwnerIndex(messages, streamId)
|
||||
if (ownerIndex === -1) return false
|
||||
|
||||
const assistantIndex = findAssistantAfterOwner(messages, ownerIndex)
|
||||
if (assistantIndex === -1) return false
|
||||
|
||||
return isPersistedAssistantMessage(messages[assistantIndex], liveAssistantId)
|
||||
}
|
||||
|
||||
export function reconcileLiveAssistantTurn(params: {
|
||||
messages: PersistedMessage[]
|
||||
streamId: string
|
||||
liveAssistant: PersistedMessage
|
||||
activeStreamId: string | null
|
||||
}): PersistedMessage[] {
|
||||
const { messages, streamId, liveAssistant, activeStreamId } = params
|
||||
const ownerIndex = findStreamOwnerIndex(messages, streamId)
|
||||
if (ownerIndex === -1) {
|
||||
return [...messages.filter((message) => message.id !== liveAssistant.id), liveAssistant]
|
||||
}
|
||||
|
||||
const assistantIndex = findAssistantAfterOwner(messages, ownerIndex)
|
||||
const existingAssistant = assistantIndex >= 0 ? messages[assistantIndex] : undefined
|
||||
if (
|
||||
activeStreamId !== streamId &&
|
||||
existingAssistant &&
|
||||
isPersistedAssistantMessage(existingAssistant, liveAssistant.id)
|
||||
) {
|
||||
const withoutStaleLiveAssistant = messages.filter((message) => message.id !== liveAssistant.id)
|
||||
return withoutStaleLiveAssistant.length === messages.length
|
||||
? messages
|
||||
: withoutStaleLiveAssistant
|
||||
}
|
||||
|
||||
const withoutDuplicateLiveAssistant = messages.filter(
|
||||
(message, index) => index === assistantIndex || message.id !== liveAssistant.id
|
||||
)
|
||||
const adjustedOwnerIndex = withoutDuplicateLiveAssistant.findIndex(
|
||||
(message) => message.role === 'user' && message.id === streamId
|
||||
)
|
||||
const adjustedAssistantIndex =
|
||||
adjustedOwnerIndex >= 0
|
||||
? findAssistantAfterOwner(withoutDuplicateLiveAssistant, adjustedOwnerIndex)
|
||||
: -1
|
||||
|
||||
if (adjustedAssistantIndex >= 0) {
|
||||
return withoutDuplicateLiveAssistant.map((message, index) =>
|
||||
index === adjustedAssistantIndex ? liveAssistant : message
|
||||
)
|
||||
}
|
||||
|
||||
if (adjustedOwnerIndex >= 0) {
|
||||
return [
|
||||
...withoutDuplicateLiveAssistant.slice(0, adjustedOwnerIndex + 1),
|
||||
liveAssistant,
|
||||
...withoutDuplicateLiveAssistant.slice(adjustedOwnerIndex + 1),
|
||||
]
|
||||
}
|
||||
|
||||
return [...withoutDuplicateLiveAssistant, liveAssistant]
|
||||
}
|
||||
|
||||
export interface ReconnectReplaySelection {
|
||||
afterCursor: string
|
||||
content: string
|
||||
contentBlocks: ContentBlock[]
|
||||
preserveExistingState: boolean
|
||||
source: 'cache' | 'reset'
|
||||
}
|
||||
|
||||
export function selectReconnectReplayState(params: {
|
||||
afterCursor: string
|
||||
cachedLiveAssistant?: Pick<ChatMessage, 'content' | 'contentBlocks'> | null
|
||||
currentContent: string
|
||||
currentBlocks: ContentBlock[]
|
||||
}): ReconnectReplaySelection {
|
||||
const { afterCursor, cachedLiveAssistant, currentContent, currentBlocks } = params
|
||||
if (isZeroStreamCursor(afterCursor)) {
|
||||
return {
|
||||
afterCursor,
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
preserveExistingState: false,
|
||||
source: 'reset',
|
||||
}
|
||||
}
|
||||
|
||||
const cachedContent = cachedLiveAssistant?.content ?? ''
|
||||
const cachedBlocks = cachedLiveAssistant?.contentBlocks ?? []
|
||||
const cachedHasLiveState = cachedContent.length > 0 || cachedBlocks.length > 0
|
||||
const cachedIsAhead =
|
||||
cachedHasLiveState &&
|
||||
cachedContent.length >= currentContent.length &&
|
||||
cachedContent.startsWith(currentContent) &&
|
||||
cachedBlocks.length >= currentBlocks.length
|
||||
|
||||
if (cachedIsAhead) {
|
||||
return {
|
||||
afterCursor,
|
||||
content: cachedContent,
|
||||
contentBlocks: [...cachedBlocks],
|
||||
preserveExistingState: true,
|
||||
source: 'cache',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
afterCursor: '0',
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
preserveExistingState: false,
|
||||
source: 'reset',
|
||||
}
|
||||
}
|
||||
|
||||
export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set<string> {
|
||||
const completedToolCallIds = new Set<string>()
|
||||
for (const entry of events) {
|
||||
const event = entry.event
|
||||
if (event.type !== MothershipStreamV1EventType.tool) continue
|
||||
const payload = event.payload
|
||||
if (!('phase' in payload)) continue
|
||||
if (payload.phase !== MothershipStreamV1ToolPhase.result) continue
|
||||
if (typeof payload.toolCallId === 'string' && isWorkflowToolName(payload.toolName)) {
|
||||
completedToolCallIds.add(payload.toolCallId)
|
||||
}
|
||||
}
|
||||
return completedToolCallIds
|
||||
}
|
||||
|
||||
function buildRecoverySubjectKey(
|
||||
chatId: string | undefined,
|
||||
selectedChatId: string | undefined
|
||||
@@ -1556,7 +1717,7 @@ export function useChat(
|
||||
expectedGen?: number,
|
||||
options?: {
|
||||
preserveExistingState?: boolean
|
||||
suppressWorkflowToolStarts?: boolean
|
||||
suppressedWorkflowToolStartIds?: ReadonlySet<string>
|
||||
targetChatId?: string
|
||||
shouldContinue?: () => boolean
|
||||
}
|
||||
@@ -1735,6 +1896,45 @@ export function useChat(
|
||||
streamingBlocksRef.current = []
|
||||
}, [])
|
||||
|
||||
const applyReconnectReplaySelection = useCallback(
|
||||
(
|
||||
streamId: string,
|
||||
assistantId: string,
|
||||
afterCursor: string,
|
||||
options?: { targetChatId?: string; chatHistory?: TaskChatHistory }
|
||||
): ReconnectReplaySelection => {
|
||||
const cachedHistory =
|
||||
options?.chatHistory ??
|
||||
(options?.targetChatId
|
||||
? queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(options.targetChatId))
|
||||
: undefined)
|
||||
const cachedLiveAssistant = cachedHistory?.messages.find(
|
||||
(message) => message.id === assistantId
|
||||
)
|
||||
const selection = selectReconnectReplayState({
|
||||
afterCursor,
|
||||
cachedLiveAssistant: cachedLiveAssistant ? toDisplayMessage(cachedLiveAssistant) : null,
|
||||
currentContent: streamingContentRef.current,
|
||||
currentBlocks: streamingBlocksRef.current,
|
||||
})
|
||||
|
||||
streamingContentRef.current = selection.content
|
||||
streamingBlocksRef.current = selection.contentBlocks
|
||||
lastCursorRef.current = selection.afterCursor
|
||||
|
||||
if (selection.afterCursor === '0' && afterCursor !== '0') {
|
||||
logger.info('Resetting stream replay cursor after reconnect state mismatch', {
|
||||
streamId,
|
||||
targetChatId: options?.targetChatId ?? cachedHistory?.id,
|
||||
previousCursor: afterCursor,
|
||||
})
|
||||
}
|
||||
|
||||
return selection
|
||||
},
|
||||
[queryClient]
|
||||
)
|
||||
|
||||
const clearActiveTurn = useCallback(() => {
|
||||
activeTurnRef.current = null
|
||||
pendingUserMsgRef.current = null
|
||||
@@ -2075,12 +2275,32 @@ export function useChat(
|
||||
const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId
|
||||
const reconnectAfterCursor =
|
||||
previousStreamId === activeStreamId ? lastCursorRef.current || '0' : '0'
|
||||
cancelActiveStreamRecovery()
|
||||
const replacedController = abortControllerRef.current
|
||||
if (replacedController && !replacedController.signal.aborted) {
|
||||
replacedController.abort('superseded_chat_history_reconnect')
|
||||
}
|
||||
cancelActiveStreamReader()
|
||||
abortControllerRef.current = abortController
|
||||
streamIdRef.current = activeStreamId
|
||||
lastCursorRef.current = reconnectAfterCursor
|
||||
setTransportReconnecting()
|
||||
|
||||
const assistantId = getLiveAssistantMessageId(activeStreamId)
|
||||
let snapshotReplayAfterCursor: string
|
||||
if (snapshotEvents.length > 0) {
|
||||
streamingContentRef.current = ''
|
||||
streamingBlocksRef.current = []
|
||||
lastCursorRef.current = '0'
|
||||
snapshotReplayAfterCursor = '0'
|
||||
} else {
|
||||
const replaySelection = applyReconnectReplaySelection(
|
||||
activeStreamId,
|
||||
assistantId,
|
||||
reconnectAfterCursor,
|
||||
{ targetChatId: chatHistory.id, chatHistory }
|
||||
)
|
||||
snapshotReplayAfterCursor = replaySelection.afterCursor
|
||||
}
|
||||
|
||||
const reconnect = async () => {
|
||||
const initialSnapshot = chatHistory.streamSnapshot
|
||||
@@ -2091,7 +2311,8 @@ export function useChat(
|
||||
let reconnectResult: Awaited<ReturnType<typeof attachToExistingStreamRef.current>> | null =
|
||||
null
|
||||
const replaySnapshotEvents = snapshotEvents.filter(
|
||||
(entry) => !isAlreadyProcessedStreamCursor(String(entry.eventId), reconnectAfterCursor)
|
||||
(entry) =>
|
||||
!isAlreadyProcessedStreamCursor(String(entry.eventId), snapshotReplayAfterCursor)
|
||||
)
|
||||
if (replaySnapshotEvents.length > 0) {
|
||||
try {
|
||||
@@ -2105,7 +2326,7 @@ export function useChat(
|
||||
previewSessions: snapshotPreviewSessions,
|
||||
status: initialSnapshot?.status ?? 'unknown',
|
||||
},
|
||||
afterCursor: reconnectAfterCursor,
|
||||
afterCursor: snapshotReplayAfterCursor,
|
||||
targetChatId: chatHistory.id,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -2150,9 +2371,12 @@ export function useChat(
|
||||
}, [
|
||||
chatHistory,
|
||||
workspaceId,
|
||||
cancelActiveStreamReader,
|
||||
cancelActiveStreamRecovery,
|
||||
queryClient,
|
||||
recoverPendingClientWorkflowTools,
|
||||
seedPreviewSessions,
|
||||
applyReconnectReplaySelection,
|
||||
setTransportIdle,
|
||||
setTransportReconnecting,
|
||||
])
|
||||
@@ -2164,7 +2388,7 @@ export function useChat(
|
||||
expectedGen?: number,
|
||||
options?: {
|
||||
preserveExistingState?: boolean
|
||||
suppressWorkflowToolStarts?: boolean
|
||||
suppressedWorkflowToolStartIds?: ReadonlySet<string>
|
||||
targetChatId?: string
|
||||
shouldContinue?: () => boolean
|
||||
}
|
||||
@@ -2372,14 +2596,27 @@ export function useChat(
|
||||
contentBlocks: blocks,
|
||||
...(streamRequestId ? { requestId: streamRequestId } : {}),
|
||||
})
|
||||
upsertTaskChatHistory(activeChatId, (current) => ({
|
||||
...current,
|
||||
messages: [
|
||||
...current.messages.filter((message) => message.id !== assistantId),
|
||||
assistantMessage,
|
||||
],
|
||||
activeStreamId: streamIdRef.current ?? current.activeStreamId,
|
||||
}))
|
||||
upsertTaskChatHistory(activeChatId, (current) => {
|
||||
const streamId = streamIdRef.current ?? current.activeStreamId ?? assistantId
|
||||
const terminalPersistedAssistantExists =
|
||||
current.activeStreamId !== streamId &&
|
||||
hasTerminalPersistedAssistantForStream(current.messages, streamId, assistantMessage.id)
|
||||
const reconciledMessages = reconcileLiveAssistantTurn({
|
||||
messages: current.messages,
|
||||
streamId,
|
||||
liveAssistant: assistantMessage,
|
||||
activeStreamId: current.activeStreamId,
|
||||
})
|
||||
const skippedTerminalLiveWrite = reconciledMessages === current.messages
|
||||
return {
|
||||
...current,
|
||||
messages: reconciledMessages,
|
||||
activeStreamId:
|
||||
skippedTerminalLiveWrite || terminalPersistedAssistantExists
|
||||
? current.activeStreamId
|
||||
: (streamIdRef.current ?? current.activeStreamId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const flushText = () => {
|
||||
@@ -2951,7 +3188,7 @@ export function useChat(
|
||||
|
||||
if (isWorkflowToolName(name) && !isPartial) {
|
||||
const shouldStartWorkflowTool =
|
||||
!options?.suppressWorkflowToolStarts &&
|
||||
!options?.suppressedWorkflowToolStartIds?.has(id) &&
|
||||
(isNewToolCall ||
|
||||
(existingToolCall?.status === ToolCallStatus.executing &&
|
||||
!existingToolCall.result))
|
||||
@@ -3392,10 +3629,6 @@ export function useChat(
|
||||
targetChatId,
|
||||
shouldContinue,
|
||||
} = opts
|
||||
let latestCursor = afterCursor
|
||||
let seedEvents = opts.initialBatch?.events ?? []
|
||||
let streamStatus = opts.initialBatch?.status ?? 'unknown'
|
||||
let suppressSeedWorkflowStarts = seedEvents.length > 0
|
||||
|
||||
const isStaleReconnect = () =>
|
||||
streamGenRef.current !== expectedGen ||
|
||||
@@ -3406,6 +3639,20 @@ export function useChat(
|
||||
return { error: false, aborted: true }
|
||||
}
|
||||
|
||||
const initialReplaySelection: Pick<
|
||||
ReconnectReplaySelection,
|
||||
'afterCursor' | 'preserveExistingState'
|
||||
> = opts.initialBatch
|
||||
? { afterCursor, preserveExistingState: true }
|
||||
: applyReconnectReplaySelection(streamId, assistantId, afterCursor, {
|
||||
...(targetChatId ? { targetChatId } : {}),
|
||||
})
|
||||
let latestCursor = initialReplaySelection.afterCursor
|
||||
let preserveNextReplayState = initialReplaySelection.preserveExistingState
|
||||
let seedEvents = opts.initialBatch?.events ?? []
|
||||
let streamStatus = opts.initialBatch?.status ?? 'unknown'
|
||||
let suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents)
|
||||
|
||||
setTransportReconnecting()
|
||||
setError(null)
|
||||
|
||||
@@ -3417,8 +3664,8 @@ export function useChat(
|
||||
assistantId,
|
||||
expectedGen,
|
||||
{
|
||||
preserveExistingState: true,
|
||||
suppressWorkflowToolStarts: suppressSeedWorkflowStarts,
|
||||
preserveExistingState: preserveNextReplayState,
|
||||
suppressedWorkflowToolStartIds: suppressedSeedWorkflowToolStartIds,
|
||||
...(targetChatId ? { targetChatId } : {}),
|
||||
...(shouldContinue ? { shouldContinue } : {}),
|
||||
}
|
||||
@@ -3429,7 +3676,8 @@ export function useChat(
|
||||
latestCursor = String(seedEvents[seedEvents.length - 1]?.eventId ?? latestCursor)
|
||||
lastCursorRef.current = latestCursor
|
||||
seedEvents = []
|
||||
suppressSeedWorkflowStarts = false
|
||||
preserveNextReplayState = true
|
||||
suppressedSeedWorkflowToolStartIds = new Set()
|
||||
|
||||
if (replayResult.sawStreamError) {
|
||||
return { error: true, aborted: false }
|
||||
@@ -3475,11 +3723,12 @@ export function useChat(
|
||||
assistantId,
|
||||
expectedGen,
|
||||
{
|
||||
preserveExistingState: true,
|
||||
preserveExistingState: preserveNextReplayState,
|
||||
...(targetChatId ? { targetChatId } : {}),
|
||||
...(shouldContinue ? { shouldContinue } : {}),
|
||||
}
|
||||
)
|
||||
preserveNextReplayState = true
|
||||
|
||||
if (liveResult.sawStreamError) {
|
||||
return { error: true, aborted: false }
|
||||
@@ -3509,6 +3758,7 @@ export function useChat(
|
||||
seedStreamBatchPreviewSessions(batch)
|
||||
seedEvents = batch.events
|
||||
streamStatus = batch.status
|
||||
suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents)
|
||||
|
||||
if (batch.events.length > 0) {
|
||||
latestCursor = String(batch.events[batch.events.length - 1].eventId)
|
||||
@@ -3538,6 +3788,7 @@ export function useChat(
|
||||
}
|
||||
},
|
||||
[
|
||||
applyReconnectReplaySelection,
|
||||
fetchStreamBatch,
|
||||
seedStreamBatchPreviewSessions,
|
||||
setTransportIdle,
|
||||
@@ -3559,7 +3810,12 @@ export function useChat(
|
||||
}): Promise<void> => {
|
||||
const { streamId, assistantId, gen, afterCursor, signal, targetChatId, shouldContinue } = opts
|
||||
|
||||
const batch = await fetchStreamBatch(streamId, afterCursor, signal)
|
||||
if (streamGenRef.current !== gen || signal?.aborted || shouldContinue?.() === false) return
|
||||
|
||||
const replaySelection = applyReconnectReplaySelection(streamId, assistantId, afterCursor, {
|
||||
...(targetChatId ? { targetChatId } : {}),
|
||||
})
|
||||
const batch = await fetchStreamBatch(streamId, replaySelection.afterCursor, signal)
|
||||
if (streamGenRef.current !== gen || shouldContinue?.() === false) return
|
||||
seedStreamBatchPreviewSessions(batch)
|
||||
|
||||
@@ -3570,7 +3826,8 @@ export function useChat(
|
||||
assistantId,
|
||||
gen,
|
||||
{
|
||||
preserveExistingState: true,
|
||||
preserveExistingState: replaySelection.preserveExistingState,
|
||||
suppressedWorkflowToolStartIds: getReplayCompletedWorkflowToolCallIds(batch.events),
|
||||
...(targetChatId ? { targetChatId } : {}),
|
||||
...(shouldContinue ? { shouldContinue } : {}),
|
||||
}
|
||||
@@ -3594,7 +3851,7 @@ export function useChat(
|
||||
afterCursor:
|
||||
batch.events.length > 0
|
||||
? String(batch.events[batch.events.length - 1].eventId)
|
||||
: afterCursor,
|
||||
: replaySelection.afterCursor,
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -3615,7 +3872,13 @@ export function useChat(
|
||||
setTransportIdle()
|
||||
}
|
||||
},
|
||||
[fetchStreamBatch, seedStreamBatchPreviewSessions, attachToExistingStream, setTransportIdle]
|
||||
[
|
||||
applyReconnectReplaySelection,
|
||||
fetchStreamBatch,
|
||||
seedStreamBatchPreviewSessions,
|
||||
attachToExistingStream,
|
||||
setTransportIdle,
|
||||
]
|
||||
)
|
||||
|
||||
const retryReconnect = useCallback(
|
||||
@@ -3782,6 +4045,8 @@ export function useChat(
|
||||
}
|
||||
|
||||
const recoveryGen = observedGeneration + 1
|
||||
const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId
|
||||
const afterCursor = previousStreamId === streamId ? lastCursorRef.current || '0' : '0'
|
||||
streamGenRef.current = recoveryGen
|
||||
setTransportReconnecting()
|
||||
streamIdRef.current = streamId
|
||||
@@ -3821,7 +4086,6 @@ export function useChat(
|
||||
if (locallyTerminalStreamIdRef.current === streamId) return
|
||||
|
||||
const assistantId = getLiveAssistantMessageId(streamId)
|
||||
const afterCursor = lastCursorRef.current || '0'
|
||||
|
||||
try {
|
||||
await resumeOrFinalize({
|
||||
|
||||
@@ -37,6 +37,38 @@ describe('resolveToolDisplay', () => {
|
||||
).toBe('Read RET XYZ')
|
||||
})
|
||||
|
||||
it('formats special workspace file reads as natural language', () => {
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.error, {
|
||||
path: 'files/haiku_collection_sim.pptx/compiled-check',
|
||||
})?.text
|
||||
).toBe('Attempted to read the final file check for haiku_collection_sim.pptx')
|
||||
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
|
||||
path: 'files/by-id/87c18b84-2f83-43a4-bed8-8a86f7d42022/compiled-check',
|
||||
})?.text
|
||||
).toBe('Read the final file check for this file')
|
||||
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
|
||||
path: 'files/by-id/625094cc-2f64-4de9-a39c-452cb8283bb1/content',
|
||||
})?.text
|
||||
).toBe('Read the content of this file')
|
||||
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
|
||||
path: 'files/report.pdf/meta.json',
|
||||
})?.text
|
||||
).toBe('Reading metadata for report.pdf')
|
||||
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
|
||||
path: 'files/deck.pptx/style',
|
||||
})?.text
|
||||
).toBe('Read style details for deck.pptx')
|
||||
})
|
||||
|
||||
it('falls back to a humanized tool label for generic tools', () => {
|
||||
expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe(
|
||||
'Executed Deploy Api'
|
||||
|
||||
@@ -96,7 +96,7 @@ function describeReadTarget(path: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
if (resourceType === 'file') {
|
||||
return segments.slice(1).join('/') || segments[segments.length - 1]
|
||||
return describeFileReadTarget(segments)
|
||||
}
|
||||
|
||||
if (resourceType === 'workflow') {
|
||||
@@ -107,6 +107,32 @@ function describeReadTarget(path: string | undefined): string | undefined {
|
||||
return stripExtension(resourceName)
|
||||
}
|
||||
|
||||
const FILE_SPECIAL_READ_TARGET_PREFIXES: Record<string, string> = {
|
||||
content: 'the content of',
|
||||
'meta.json': 'metadata for',
|
||||
style: 'style details for',
|
||||
'compiled-check': 'the final file check for',
|
||||
}
|
||||
|
||||
function describeFileReadTarget(segments: string[]): string {
|
||||
const lastSegment = segments[segments.length - 1] || ''
|
||||
const specialPrefix = FILE_SPECIAL_READ_TARGET_PREFIXES[lastSegment]
|
||||
if (specialPrefix) {
|
||||
return `${specialPrefix} ${describeSpecialFilePathSubject(segments)}`
|
||||
}
|
||||
|
||||
return segments.slice(1).join('/') || lastSegment
|
||||
}
|
||||
|
||||
function describeSpecialFilePathSubject(segments: string[]): string {
|
||||
if (segments[1] === 'by-id') {
|
||||
const namedRemainder = segments.slice(3, -1).join('/')
|
||||
return namedRemainder || 'this file'
|
||||
}
|
||||
|
||||
return segments.slice(1, -1).join('/') || 'this file'
|
||||
}
|
||||
|
||||
function getLeafResourceSegment(segments: string[]): string {
|
||||
const lastSegment = segments[segments.length - 1] || ''
|
||||
if (hasFileExtension(lastSegment) && segments.length > 1) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getWorkspaceFileMock } = vi.hoisted(() => ({
|
||||
getWorkspaceFileMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
getWorkspaceFile: getWorkspaceFileMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
getTableById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/service', () => ({
|
||||
getKnowledgeBaseById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/service', () => ({
|
||||
getLogById: vi.fn(),
|
||||
}))
|
||||
|
||||
import { executeOpenResource } from './resources'
|
||||
|
||||
describe('executeOpenResource', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens workspace files with canonical non-UUID file ids', async () => {
|
||||
getWorkspaceFileMock.mockResolvedValue({
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
name: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
})
|
||||
|
||||
const result = await executeOpenResource(
|
||||
{
|
||||
resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }],
|
||||
},
|
||||
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(getWorkspaceFileMock).toHaveBeenCalledWith('workspace-1', 'wf_qL_cfff-FskMsXtOdm599')
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
output: { opened: 1, errors: [] },
|
||||
resources: [
|
||||
{
|
||||
type: 'file',
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
title: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import { getLogById } from '@/lib/logs/service'
|
||||
import { getTableById } from '@/lib/table/service'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { isUuid } from '@/executor/constants'
|
||||
import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types'
|
||||
|
||||
const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType))
|
||||
@@ -21,8 +20,6 @@ async function resolveResource(
|
||||
if (resourceType === 'file') {
|
||||
if (!context.workspaceId)
|
||||
return { error: 'Opening a workspace file requires workspace context.' }
|
||||
if (!isUuid(item.id))
|
||||
return { error: 'open_resource for files requires the canonical file UUID.' }
|
||||
const record = await getWorkspaceFile(context.workspaceId, item.id)
|
||||
if (!record) return { error: `No workspace file with id "${item.id}".` }
|
||||
resourceId = record.id
|
||||
|
||||
@@ -674,7 +674,7 @@ export function getSandboxWorkspaceFilePath(
|
||||
|
||||
/**
|
||||
* Find a workspace file record in an existing list from either its id or a VFS/name reference.
|
||||
* For copilot `open_resource` and the resource panel, use {@link getWorkspaceFile} with a UUID only.
|
||||
* For copilot `open_resource` and the resource panel, use {@link getWorkspaceFile} with the file id.
|
||||
*/
|
||||
export function findWorkspaceFileRecord(
|
||||
files: WorkspaceFileRecord[],
|
||||
|
||||
Reference in New Issue
Block a user