From 139fc236f13c99dc036d544531ba51632b915270 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 8 May 2026 18:49:25 -0700 Subject: [PATCH 1/2] fix(vfs); compiled check tool call natural language tool desc --- .../lib/copilot/tools/client/store-utils.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 6780db1280..b8ea70a368 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -96,6 +96,9 @@ function describeReadTarget(path: string | undefined): string | undefined { } if (resourceType === 'file') { + const compiledCheckTarget = describeCompiledCheckTarget(segments) + if (compiledCheckTarget) return compiledCheckTarget + return segments.slice(1).join('/') || segments[segments.length - 1] } @@ -107,6 +110,23 @@ function describeReadTarget(path: string | undefined): string | undefined { return stripExtension(resourceName) } +function describeCompiledCheckTarget(segments: string[]): string | undefined { + if (segments[segments.length - 1] !== 'compiled-check') return undefined + + const byIdIndex = segments.indexOf('by-id') + if (byIdIndex >= 0) { + const fileName = segments[byIdIndex + 2] + if (fileName && fileName !== 'compiled-check') { + return `the compile check for ${fileName}` + } + return 'the compile check for this file' + } + + const fileName = segments.slice(1, -1).join('/') + if (!fileName) return 'the compile check for this file' + return `the compile check for ${fileName}` +} + function getLeafResourceSegment(segments: string[]): string { const lastSegment = segments[segments.length - 1] || '' if (hasFileExtension(lastSegment) && segments.length > 1) { From a730c86d6fe347bedf0bb75fdff0929cc607df1e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 8 May 2026 20:51:10 -0700 Subject: [PATCH 2/2] fix few more ui/ux bugs --- .../components/plus-menu-dropdown.tsx | 2 + .../[workspaceId]/home/hooks/use-chat.test.ts | 234 +++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 318 ++++++++++++++++-- .../copilot/tools/client/store-utils.test.ts | 32 ++ .../lib/copilot/tools/client/store-utils.ts | 38 ++- .../copilot/tools/handlers/resources.test.ts | 70 ++++ .../lib/copilot/tools/handlers/resources.ts | 3 - .../workspace/workspace-file-manager.ts | 2 +- 8 files changed, 652 insertions(+), 47 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/resources.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 5403aa76fb..077727a005 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -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. diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts new file mode 100644 index 0000000000..df2631a16f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -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'])) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8e6d5bc49d..4e7d5138e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -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 | 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 { + const completedToolCallIds = new Set() + 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 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(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> | 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 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 => { 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({ diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 3c3bba5151..78ae7528e6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -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' diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index b8ea70a368..dafa45ecc6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -96,10 +96,7 @@ function describeReadTarget(path: string | undefined): string | undefined { } if (resourceType === 'file') { - const compiledCheckTarget = describeCompiledCheckTarget(segments) - if (compiledCheckTarget) return compiledCheckTarget - - return segments.slice(1).join('/') || segments[segments.length - 1] + return describeFileReadTarget(segments) } if (resourceType === 'workflow') { @@ -110,21 +107,30 @@ function describeReadTarget(path: string | undefined): string | undefined { return stripExtension(resourceName) } -function describeCompiledCheckTarget(segments: string[]): string | undefined { - if (segments[segments.length - 1] !== 'compiled-check') return undefined +const FILE_SPECIAL_READ_TARGET_PREFIXES: Record = { + content: 'the content of', + 'meta.json': 'metadata for', + style: 'style details for', + 'compiled-check': 'the final file check for', +} - const byIdIndex = segments.indexOf('by-id') - if (byIdIndex >= 0) { - const fileName = segments[byIdIndex + 2] - if (fileName && fileName !== 'compiled-check') { - return `the compile check for ${fileName}` - } - return 'the compile check for this file' +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' } - const fileName = segments.slice(1, -1).join('/') - if (!fileName) return 'the compile check for this file' - return `the compile check for ${fileName}` + return segments.slice(1, -1).join('/') || 'this file' } function getLeafResourceSegment(segments: string[]): string { diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts new file mode 100644 index 0000000000..d573959f9d --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -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', + }, + ], + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index cae410bf8c..338f187de3 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -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 diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index a5508b5476..fda46789dc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -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[],