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:
Vikhyath Mondreti
2026-05-08 22:22:48 -07:00
committed by GitHub
parent b74f8dac40
commit d0b0ede39e
8 changed files with 657 additions and 32 deletions
@@ -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[],