mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
v0.6.42: mothership nested file reads, search modal improvements
This commit is contained in:
@@ -688,11 +688,16 @@ async function handleBuildToolCall(
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
return authorization.allowed ? { workflowId } : null
|
||||
return authorization.allowed
|
||||
? { status: 'resolved' as const, workflowId }
|
||||
: {
|
||||
status: 'not_found' as const,
|
||||
message: 'workflowId is required for build. Call create_workflow first.',
|
||||
}
|
||||
})()
|
||||
: await resolveWorkflowIdForUser(userId)
|
||||
|
||||
if (!resolved?.workflowId) {
|
||||
if (!resolved || resolved.status !== 'resolved') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -700,7 +705,9 @@ async function handleBuildToolCall(
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: 'workflowId is required for build. Call create_workflow first.',
|
||||
error:
|
||||
resolved?.message ??
|
||||
'workflowId is required for build. Call create_workflow first.',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -29,8 +29,8 @@ const RequestSchema = z.object({
|
||||
*
|
||||
* workflowId is optional - if not provided:
|
||||
* - If workflowName is provided, finds that workflow
|
||||
* - Otherwise uses the user's first workflow as context
|
||||
* - The copilot can still operate on any workflow using list_user_workflows
|
||||
* - If exactly one workflow is available, uses that workflow as context
|
||||
* - Otherwise requires workflowId or workflowName to disambiguate
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let messageId: string | undefined
|
||||
@@ -54,11 +54,11 @@ export async function POST(req: NextRequest) {
|
||||
parsed.workflowName,
|
||||
auth.keyType === 'workspace' ? auth.workspaceId : undefined
|
||||
)
|
||||
if (!resolved) {
|
||||
if (resolved.status !== 'resolved') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
error: resolved.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
|
||||
@@ -21,7 +21,13 @@ interface AgentGroupProps {
|
||||
}
|
||||
|
||||
function isToolDone(status: ToolCallData['status']): boolean {
|
||||
return status === 'success' || status === 'error' || status === 'cancelled'
|
||||
return (
|
||||
status === 'success' ||
|
||||
status === 'error' ||
|
||||
status === 'cancelled' ||
|
||||
status === 'skipped' ||
|
||||
status === 'rejected'
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentGroup({
|
||||
|
||||
@@ -70,7 +70,13 @@ function resolveAgentLabel(key: string): string {
|
||||
}
|
||||
|
||||
function isToolDone(status: ToolCallData['status']): boolean {
|
||||
return status === 'success' || status === 'error' || status === 'cancelled'
|
||||
return (
|
||||
status === 'success' ||
|
||||
status === 'error' ||
|
||||
status === 'cancelled' ||
|
||||
status === 'skipped' ||
|
||||
status === 'rejected'
|
||||
)
|
||||
}
|
||||
|
||||
function isDelegatingTool(tc: NonNullable<ContentBlock['toolCall']>): boolean {
|
||||
@@ -87,6 +93,10 @@ function mapToolStatusToClientState(
|
||||
return ClientToolCallState.error
|
||||
case 'cancelled':
|
||||
return ClientToolCallState.cancelled
|
||||
case 'skipped':
|
||||
return ClientToolCallState.aborted
|
||||
case 'rejected':
|
||||
return ClientToolCallState.rejected
|
||||
default:
|
||||
return ClientToolCallState.executing
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) {
|
||||
{entry.status === 'error' && (
|
||||
<span className='ml-auto text-[12px] text-[var(--text-error)]'>Error</span>
|
||||
)}
|
||||
{entry.status === 'skipped' && (
|
||||
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Skipped</span>
|
||||
)}
|
||||
{entry.status === 'rejected' && (
|
||||
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Rejected</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.streamingArgs && (
|
||||
<pre className='overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] text-[var(--text-body)]'>
|
||||
|
||||
@@ -119,6 +119,7 @@ import type {
|
||||
MothershipResourceType,
|
||||
QueuedMessage,
|
||||
} from '../types'
|
||||
import { ToolCallStatus } from '../types'
|
||||
|
||||
const FILE_SUBAGENT_ID = 'file'
|
||||
|
||||
@@ -610,6 +611,28 @@ function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLiveToolStatus(
|
||||
payload: Partial<{
|
||||
status: string
|
||||
success: boolean
|
||||
}>
|
||||
): ToolCallStatus {
|
||||
switch (payload.status) {
|
||||
case MothershipStreamV1ToolOutcome.success:
|
||||
return ToolCallStatus.success
|
||||
case MothershipStreamV1ToolOutcome.error:
|
||||
return ToolCallStatus.error
|
||||
case MothershipStreamV1ToolOutcome.cancelled:
|
||||
return ToolCallStatus.cancelled
|
||||
case MothershipStreamV1ToolOutcome.skipped:
|
||||
return ToolCallStatus.skipped
|
||||
case MothershipStreamV1ToolOutcome.rejected:
|
||||
return ToolCallStatus.rejected
|
||||
default:
|
||||
return payload.success === true ? ToolCallStatus.success : ToolCallStatus.error
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
|
||||
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
|
||||
const workflows = getWorkflows(workspaceId)
|
||||
@@ -650,7 +673,10 @@ function extractResourceFromReadResult(
|
||||
): MothershipResource | null {
|
||||
if (!path) return null
|
||||
|
||||
const segments = path.split('/')
|
||||
const segments = path
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
|
||||
if (!resourceType || !segments[1]) return null
|
||||
|
||||
@@ -670,8 +696,22 @@ function extractResourceFromReadResult(
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
resourceType === 'workflow'
|
||||
? resolveLeafWorkflowPathSegment(segments)
|
||||
: segments[1] || segments[segments.length - 1]
|
||||
|
||||
if (!id) return null
|
||||
return { type: resourceType, id, title: name || segments[1] }
|
||||
return { type: resourceType, id, title: name || fallbackTitle || id }
|
||||
}
|
||||
|
||||
function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined {
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
if (!lastSegment) return undefined
|
||||
if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) {
|
||||
return segments[segments.length - 2]
|
||||
}
|
||||
return lastSegment
|
||||
}
|
||||
|
||||
export interface UseChatOptions {
|
||||
@@ -1396,6 +1436,7 @@ export function useChat(
|
||||
let activeSubagent: string | undefined
|
||||
let activeSubagentParentToolCallId: string | undefined
|
||||
let activeCompactionId: string | undefined
|
||||
const subagentByParentToolCallId = new Map<string, string>()
|
||||
|
||||
if (preserveState) {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
@@ -1418,20 +1459,32 @@ export function useChat(
|
||||
streamingBlocksRef.current = []
|
||||
}
|
||||
|
||||
const ensureTextBlock = (): ContentBlock => {
|
||||
const ensureTextBlock = (subagentName?: string): ContentBlock => {
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.type === 'text' && last.subagent === activeSubagent) return last
|
||||
if (last?.type === 'text' && last.subagent === subagentName) return last
|
||||
const b: ContentBlock = { type: 'text', content: '' }
|
||||
if (subagentName) b.subagent = subagentName
|
||||
blocks.push(b)
|
||||
return b
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (tag: string) => {
|
||||
const resolveScopedSubagent = (
|
||||
agentId: string | undefined,
|
||||
parentToolCallId: string | undefined
|
||||
): string | undefined => {
|
||||
if (agentId) return agentId
|
||||
if (parentToolCallId) {
|
||||
const scoped = subagentByParentToolCallId.get(parentToolCallId)
|
||||
if (scoped) return scoped
|
||||
}
|
||||
return activeSubagent
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (tag: string, subagentName?: string) => {
|
||||
if (runningText.includes(tag)) return
|
||||
const tb = ensureTextBlock()
|
||||
const tb = ensureTextBlock(subagentName)
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
tb.content = `${tb.content ?? ''}${prefix}${tag}`
|
||||
if (activeSubagent) tb.subagent = activeSubagent
|
||||
runningText += `${prefix}${tag}`
|
||||
streamingContentRef.current = runningText
|
||||
flush()
|
||||
@@ -1545,6 +1598,13 @@ export function useChat(
|
||||
}
|
||||
|
||||
logger.debug('SSE event received', parsed)
|
||||
const scopedParentToolCallId =
|
||||
typeof parsed.scope?.parentToolCallId === 'string'
|
||||
? parsed.scope.parentToolCallId
|
||||
: undefined
|
||||
const scopedAgentId =
|
||||
typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined
|
||||
const scopedSubagent = resolveScopedSubagent(scopedAgentId, scopedParentToolCallId)
|
||||
switch (parsed.type) {
|
||||
case MothershipStreamV1EventType.session: {
|
||||
const payload = parsed.payload
|
||||
@@ -1600,16 +1660,15 @@ export function useChat(
|
||||
case MothershipStreamV1EventType.text: {
|
||||
const chunk = parsed.payload.text
|
||||
if (chunk) {
|
||||
const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main'
|
||||
const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main'
|
||||
const needsBoundaryNewline =
|
||||
lastContentSource !== null &&
|
||||
lastContentSource !== contentSource &&
|
||||
runningText.length > 0 &&
|
||||
!runningText.endsWith('\n')
|
||||
const tb = ensureTextBlock()
|
||||
const tb = ensureTextBlock(scopedSubagent)
|
||||
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
|
||||
tb.content = (tb.content ?? '') + normalizedChunk
|
||||
if (activeSubagent) tb.subagent = activeSubagent
|
||||
runningText += normalizedChunk
|
||||
lastContentSource = contentSource
|
||||
streamingContentRef.current = runningText
|
||||
@@ -1800,22 +1859,24 @@ export function useChat(
|
||||
}
|
||||
const tc = blocks[idx].toolCall!
|
||||
const outputObj = asPayloadRecord(payload.output)
|
||||
const success =
|
||||
payload.success ?? payload.status === MothershipStreamV1ToolOutcome.success
|
||||
const isCancelled =
|
||||
outputObj?.reason === 'user_cancelled' ||
|
||||
outputObj?.cancelledByUser === true ||
|
||||
payload.status === MothershipStreamV1ToolOutcome.cancelled
|
||||
const status = isCancelled
|
||||
? ToolCallStatus.cancelled
|
||||
: resolveLiveToolStatus(payload)
|
||||
const isSuccess = status === ToolCallStatus.success
|
||||
|
||||
if (isCancelled) {
|
||||
tc.status = 'cancelled'
|
||||
if (status === ToolCallStatus.cancelled) {
|
||||
tc.status = ToolCallStatus.cancelled
|
||||
tc.displayTitle = 'Stopped by user'
|
||||
} else {
|
||||
tc.status = success ? 'success' : 'error'
|
||||
tc.status = status
|
||||
}
|
||||
tc.streamingArgs = undefined
|
||||
tc.result = {
|
||||
success: !!success,
|
||||
success: isSuccess,
|
||||
output: payload.output,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined,
|
||||
}
|
||||
@@ -1902,7 +1963,7 @@ export function useChat(
|
||||
})
|
||||
setActiveResourceId(fileResource.id)
|
||||
invalidateResourceQueries(queryClient, workspaceId, 'file', fileResource.id)
|
||||
} else if (!activeSubagent || activeSubagent !== FILE_SUBAGENT_ID) {
|
||||
} else if (tc.calledBy !== FILE_SUBAGENT_ID) {
|
||||
setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
|
||||
}
|
||||
}
|
||||
@@ -1948,7 +2009,7 @@ export function useChat(
|
||||
status: 'executing',
|
||||
displayTitle,
|
||||
params: args,
|
||||
calledBy: activeSubagent,
|
||||
calledBy: scopedSubagent,
|
||||
},
|
||||
})
|
||||
if (name === ReadTool.id || isResourceToolName(name)) {
|
||||
@@ -2064,23 +2125,18 @@ export function useChat(
|
||||
}
|
||||
const spanData = asPayloadRecord(payload.data)
|
||||
const parentToolCallId =
|
||||
typeof parsed.scope?.parentToolCallId === 'string'
|
||||
? parsed.scope.parentToolCallId
|
||||
: typeof spanData?.tool_call_id === 'string'
|
||||
? spanData.tool_call_id
|
||||
: undefined
|
||||
scopedParentToolCallId ??
|
||||
(typeof spanData?.tool_call_id === 'string' ? spanData.tool_call_id : undefined)
|
||||
const isPendingPause = spanData?.pending === true
|
||||
const name =
|
||||
typeof payload.agent === 'string'
|
||||
? payload.agent
|
||||
: typeof parsed.scope?.agentId === 'string'
|
||||
? parsed.scope.agentId
|
||||
: undefined
|
||||
const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId
|
||||
if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
|
||||
const isSameActiveSubagent =
|
||||
activeSubagent === name &&
|
||||
activeSubagentParentToolCallId &&
|
||||
parentToolCallId === activeSubagentParentToolCallId
|
||||
if (parentToolCallId) {
|
||||
subagentByParentToolCallId.set(parentToolCallId, name)
|
||||
}
|
||||
activeSubagent = name
|
||||
activeSubagentParentToolCallId = parentToolCallId
|
||||
if (!isSameActiveSubagent) {
|
||||
@@ -2104,6 +2160,9 @@ export function useChat(
|
||||
if (isPendingPause) {
|
||||
break
|
||||
}
|
||||
if (parentToolCallId) {
|
||||
subagentByParentToolCallId.delete(parentToolCallId)
|
||||
}
|
||||
if (previewSessionRef.current && !activePreviewSessionIdRef.current) {
|
||||
const lastFileResource = resourcesRef.current.find(
|
||||
(r) => r.type === 'file' && r.id !== 'streaming-file'
|
||||
@@ -2113,8 +2172,14 @@ export function useChat(
|
||||
setActiveResourceId(lastFileResource.id)
|
||||
}
|
||||
}
|
||||
activeSubagent = undefined
|
||||
activeSubagentParentToolCallId = undefined
|
||||
if (
|
||||
!parentToolCallId ||
|
||||
parentToolCallId === activeSubagentParentToolCallId ||
|
||||
name === activeSubagent
|
||||
) {
|
||||
activeSubagent = undefined
|
||||
activeSubagentParentToolCallId = undefined
|
||||
}
|
||||
blocks.push({ type: 'subagent_end' })
|
||||
flush()
|
||||
}
|
||||
@@ -2123,7 +2188,7 @@ export function useChat(
|
||||
case MothershipStreamV1EventType.error: {
|
||||
sawStreamError = true
|
||||
setError(parsed.payload.message || parsed.payload.error || 'An error occurred')
|
||||
appendInlineErrorTag(buildInlineErrorTag(parsed.payload))
|
||||
appendInlineErrorTag(buildInlineErrorTag(parsed.payload), scopedSubagent)
|
||||
break
|
||||
}
|
||||
case MothershipStreamV1EventType.complete: {
|
||||
|
||||
@@ -59,6 +59,8 @@ export const ToolCallStatus = {
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
cancelled: 'cancelled',
|
||||
skipped: 'skipped',
|
||||
rejected: 'rejected',
|
||||
} as const
|
||||
export type ToolCallStatus = (typeof ToolCallStatus)[keyof typeof ToolCallStatus]
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const MemoizedWorkflowItem = memo(
|
||||
onSelect: () => void
|
||||
color: string
|
||||
name: string
|
||||
folderPath?: string
|
||||
folderPath?: string[]
|
||||
isCurrent?: boolean
|
||||
}) {
|
||||
return (
|
||||
@@ -71,13 +71,21 @@ export const MemoizedWorkflowItem = memo(
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate font-base text-[var(--text-body)]'>
|
||||
{name}
|
||||
{isCurrent && ' (current)'}
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
|
||||
</span>
|
||||
{folderPath && (
|
||||
<span className='ml-auto min-w-0 truncate pl-2 font-base text-[var(--text-subtle)] text-small'>
|
||||
{folderPath}
|
||||
{folderPath && folderPath.length > 0 && (
|
||||
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
|
||||
{folderPath.length > 1 && (
|
||||
<>
|
||||
<span className='min-w-0 truncate [flex-shrink:9999]'>
|
||||
{folderPath.slice(0, -1).join(' / ')}
|
||||
</span>
|
||||
<span className='flex-shrink-0 whitespace-pre'> / </span>
|
||||
</>
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
|
||||
</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
@@ -87,8 +95,10 @@ export const MemoizedWorkflowItem = memo(
|
||||
prev.value === next.value &&
|
||||
prev.color === next.color &&
|
||||
prev.name === next.name &&
|
||||
prev.folderPath === next.folderPath &&
|
||||
prev.isCurrent === next.isCurrent
|
||||
prev.isCurrent === next.isCurrent &&
|
||||
(prev.folderPath === next.folderPath ||
|
||||
(prev.folderPath?.length === next.folderPath?.length &&
|
||||
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
|
||||
)
|
||||
|
||||
export const MemoizedTaskItem = memo(
|
||||
@@ -127,9 +137,9 @@ export const MemoizedWorkspaceItem = memo(
|
||||
}) {
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<span className='truncate font-base text-[var(--text-body)]'>
|
||||
{name}
|
||||
{isCurrent && ' (current)'}
|
||||
<span className='flex min-w-0 font-base text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
|
||||
</span>
|
||||
</Command.Item>
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
|
||||
{items.map((workflow) => (
|
||||
<MemoizedWorkflowItem
|
||||
key={workflow.id}
|
||||
value={`${workflow.name} ${workflow.folderPath ?? ''} workflow-${workflow.id}`}
|
||||
value={`${workflow.name} ${workflow.folderPath?.join(' / ') ?? ''} workflow-${workflow.id}`}
|
||||
onSelect={() => onSelect(workflow)}
|
||||
color={workflow.color}
|
||||
name={workflow.name}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface WorkflowItem {
|
||||
name: string
|
||||
href: string
|
||||
color: string
|
||||
folderPath?: string
|
||||
folderPath?: string[]
|
||||
isCurrent?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -637,16 +637,14 @@ export const Sidebar = memo(function Sidebar() {
|
||||
() =>
|
||||
regularWorkflows.map((workflow) => {
|
||||
const folderPath = workflow.folderId
|
||||
? getFolderPath(folderMap, workflow.folderId)
|
||||
.map((folder) => folder.name)
|
||||
.join(' / ')
|
||||
: ''
|
||||
? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name)
|
||||
: []
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: `/workspace/${workspaceId}/w/${workflow.id}`,
|
||||
color: workflow.color,
|
||||
folderPath: folderPath || undefined,
|
||||
folderPath: folderPath.length > 0 ? folderPath : undefined,
|
||||
isCurrent: workflow.id === workflowId,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -87,4 +87,62 @@ describe('display-message', () => {
|
||||
|
||||
expect(display.contentBlocks).toEqual([{ type: 'text', content: 'visible text' }])
|
||||
})
|
||||
|
||||
it('preserves skipped and rejected tool outcomes', () => {
|
||||
const display = toDisplayMessage({
|
||||
id: 'msg-3',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-skipped',
|
||||
name: 'read',
|
||||
state: 'skipped',
|
||||
display: { title: 'Reading workflow' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-rejected',
|
||||
name: 'run_workflow',
|
||||
state: 'rejected',
|
||||
display: { title: 'Running workflow' },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(display.contentBlocks).toEqual([
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-skipped',
|
||||
name: 'read',
|
||||
status: 'skipped',
|
||||
displayTitle: 'Reading workflow',
|
||||
params: undefined,
|
||||
calledBy: undefined,
|
||||
result: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-rejected',
|
||||
name: 'run_workflow',
|
||||
status: 'rejected',
|
||||
displayTitle: 'Running workflow',
|
||||
params: undefined,
|
||||
calledBy: undefined,
|
||||
result: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
[MothershipStreamV1ToolOutcome.success]: ToolCallStatus.success,
|
||||
[MothershipStreamV1ToolOutcome.error]: ToolCallStatus.error,
|
||||
[MothershipStreamV1ToolOutcome.cancelled]: ToolCallStatus.cancelled,
|
||||
[MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.error,
|
||||
[MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.success,
|
||||
[MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.rejected,
|
||||
[MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.skipped,
|
||||
pending: ToolCallStatus.executing,
|
||||
executing: ToolCallStatus.executing,
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ describe('handleUnifiedChatPost', () => {
|
||||
vi.clearAllMocks()
|
||||
getSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
resolveWorkflowIdForUser.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'Workflow One',
|
||||
})
|
||||
|
||||
@@ -420,10 +420,8 @@ async function resolveBranch(params: {
|
||||
workflowName,
|
||||
requestedWorkspaceId
|
||||
)
|
||||
if (!resolved) {
|
||||
return createBadRequestResponse(
|
||||
'No workflows found. Create a workflow first or provide a valid workflowId.'
|
||||
)
|
||||
if (resolved.status !== 'resolved') {
|
||||
return createBadRequestResponse(resolved.message)
|
||||
}
|
||||
|
||||
const resolvedWorkflowId = resolved.workflowId
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
|
||||
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
@@ -73,6 +74,23 @@ export interface WorkspaceMdData {
|
||||
}>
|
||||
}
|
||||
|
||||
function normalizeFolderPathForVfs(folderPath?: string | null): string | null {
|
||||
if (!folderPath) return null
|
||||
const segments = folderPath
|
||||
.split('/')
|
||||
.map((segment) => normalizeVfsSegment(segment))
|
||||
.filter(Boolean)
|
||||
return segments.length > 0 ? segments.join('/') : null
|
||||
}
|
||||
|
||||
function buildWorkflowStatePath(workflowName: string, folderPath?: string | null): string {
|
||||
const normalizedFolderPath = normalizeFolderPathForVfs(folderPath)
|
||||
const normalizedWorkflowName = normalizeVfsSegment(workflowName)
|
||||
return normalizedFolderPath
|
||||
? `workflows/${normalizedFolderPath}/${normalizedWorkflowName}/state.json`
|
||||
: `workflows/${normalizedWorkflowName}/state.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure formatting: build WORKSPACE.md content from pre-fetched data.
|
||||
* No DB access — callers are responsible for providing the data.
|
||||
@@ -115,10 +133,20 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string {
|
||||
if (wf.isDeployed) flags.push('deployed')
|
||||
if (wf.lastRunAt) flags.push(`last run: ${wf.lastRunAt.toISOString().split('T')[0]}`)
|
||||
if (flags.length > 0) parts[0] += ` — ${flags.join(', ')}`
|
||||
if (wf.folderPath) {
|
||||
parts.push(
|
||||
`${indent} VFS state path: \`${buildWorkflowStatePath(wf.name, wf.folderPath)}\``
|
||||
)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
if (data.workflows.some((workflow) => workflow.folderPath)) {
|
||||
lines.push(
|
||||
'Use the canonical VFS state path shown under nested workflows. Do not infer nested workflow paths from the leaf workflow name alone.'
|
||||
)
|
||||
}
|
||||
for (const wf of rootWorkflows) {
|
||||
lines.push(formatWf(wf, ''))
|
||||
}
|
||||
@@ -379,7 +407,8 @@ export async function generateWorkspaceContext(
|
||||
const folder = folderById.get(id)
|
||||
if (!folder) return id
|
||||
const parentPath = folder.parentId ? resolveFolderPath(folder.parentId) : ''
|
||||
const path = parentPath ? `${parentPath}/${folder.name}` : folder.name
|
||||
const normalizedName = normalizeVfsSegment(folder.name)
|
||||
const path = parentPath ? `${parentPath}/${normalizedName}` : normalizedName
|
||||
folderPathMap.set(id, path)
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ describe('resolveToolDisplay', () => {
|
||||
path: 'workflows/My Workflow/meta.json',
|
||||
})?.text
|
||||
).toBe('Read My Workflow')
|
||||
|
||||
expect(
|
||||
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
|
||||
path: 'workflows/Folder 1/RET XYZ/state.json',
|
||||
})?.text
|
||||
).toBe('Read RET XYZ')
|
||||
})
|
||||
|
||||
it('falls back to a humanized tool label for generic tools', () => {
|
||||
|
||||
@@ -98,10 +98,26 @@ function describeReadTarget(path: string | undefined): string | undefined {
|
||||
return segments.slice(1).join('/') || segments[segments.length - 1]
|
||||
}
|
||||
|
||||
if (resourceType === 'workflow') {
|
||||
return stripExtension(getLeafResourceSegment(segments))
|
||||
}
|
||||
|
||||
const resourceName = segments[1] || segments[segments.length - 1]
|
||||
return stripExtension(resourceName)
|
||||
}
|
||||
|
||||
function getLeafResourceSegment(segments: string[]): string {
|
||||
const lastSegment = segments[segments.length - 1] || ''
|
||||
if (hasFileExtension(lastSegment) && segments.length > 1) {
|
||||
return segments[segments.length - 2] || lastSegment
|
||||
}
|
||||
return lastSegment
|
||||
}
|
||||
|
||||
function hasFileExtension(value: string): boolean {
|
||||
return /\.[^/.]+$/.test(value)
|
||||
}
|
||||
|
||||
function stripExtension(value: string): string {
|
||||
return value.replace(/\.[^/.]+$/, '')
|
||||
}
|
||||
|
||||
@@ -422,13 +422,14 @@ export function serializeBlockSchema(block: BlockConfig): string {
|
||||
|
||||
/**
|
||||
* Serialize OAuth credentials for VFS environment/credentials.json.
|
||||
* Shows which integrations are connected — IDs and scopes, NOT tokens.
|
||||
* Shows which integrations are connected — IDs, roles, and scopes, NOT tokens.
|
||||
*/
|
||||
export function serializeCredentials(
|
||||
accounts: Array<{
|
||||
id?: string
|
||||
providerId: string
|
||||
displayName?: string | null
|
||||
role?: string | null
|
||||
scope: string | null
|
||||
createdAt: Date
|
||||
}>
|
||||
@@ -438,6 +439,7 @@ export function serializeCredentials(
|
||||
id: a.id || undefined,
|
||||
provider: a.providerId,
|
||||
displayName: a.displayName || undefined,
|
||||
role: a.role || undefined,
|
||||
scope: a.scope || undefined,
|
||||
connectedAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
|
||||
@@ -1367,6 +1367,7 @@ export class WorkspaceVFS {
|
||||
id: c.id,
|
||||
providerId: c.providerId,
|
||||
displayName: c.displayName,
|
||||
role: c.role,
|
||||
scope: null,
|
||||
createdAt: c.updatedAt,
|
||||
})),
|
||||
|
||||
@@ -367,6 +367,7 @@ export interface AccessibleOAuthCredential {
|
||||
id: string
|
||||
providerId: string
|
||||
displayName: string
|
||||
role: 'admin' | 'member'
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
@@ -379,6 +380,7 @@ export async function getAccessibleOAuthCredentials(
|
||||
id: credential.id,
|
||||
providerId: credential.providerId,
|
||||
displayName: credential.displayName,
|
||||
role: credentialMember.role,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
@@ -403,6 +405,7 @@ export async function getAccessibleOAuthCredentials(
|
||||
id: row.id,
|
||||
providerId: row.providerId!,
|
||||
displayName: row.displayName,
|
||||
role: row.role,
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -103,12 +103,32 @@ export async function deduplicateWorkflowName(
|
||||
return `${name} (${generateId().slice(0, 6)})`
|
||||
}
|
||||
|
||||
export type WorkflowResolutionResult =
|
||||
| {
|
||||
status: 'resolved'
|
||||
workflowId: string
|
||||
workflowName?: string
|
||||
}
|
||||
| {
|
||||
status: 'not_found'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'ambiguous'
|
||||
message: string
|
||||
candidates: Array<{
|
||||
workflowId: string
|
||||
workflowName?: string
|
||||
folderId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export async function resolveWorkflowIdForUser(
|
||||
userId: string,
|
||||
workflowId?: string,
|
||||
workflowName?: string,
|
||||
workspaceId?: string
|
||||
): Promise<{ workflowId: string; workflowName?: string } | null> {
|
||||
): Promise<WorkflowResolutionResult> {
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
@@ -116,10 +136,13 @@ export async function resolveWorkflowIdForUser(
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return null
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
return { workflowId, workflowName: wf?.name || undefined }
|
||||
return { status: 'resolved', workflowId, workflowName: wf?.name || undefined }
|
||||
}
|
||||
|
||||
const workspaceIds = await db
|
||||
@@ -132,7 +155,10 @@ export async function resolveWorkflowIdForUser(
|
||||
? workspaceIdList.filter((candidateWorkspaceId) => candidateWorkspaceId === workspaceId)
|
||||
: workspaceIdList
|
||||
if (allowedWorkspaceIds.length === 0) {
|
||||
return null
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
|
||||
const workflows = await db
|
||||
@@ -144,23 +170,62 @@ export async function resolveWorkflowIdForUser(
|
||||
.orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id))
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return null
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowName) {
|
||||
const match = workflows.find(
|
||||
const matches = workflows.filter(
|
||||
(w) =>
|
||||
String(w.name || '')
|
||||
.trim()
|
||||
.toLowerCase() === workflowName.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
return { workflowId: match.id, workflowName: match.name || undefined }
|
||||
if (matches.length === 1) {
|
||||
const [match] = matches
|
||||
return {
|
||||
status: 'resolved',
|
||||
workflowId: match.id,
|
||||
workflowName: match.name || undefined,
|
||||
}
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
message: `Multiple workflows named "${workflowName}" were found. Provide workflowId to disambiguate.`,
|
||||
candidates: matches.map((match) => ({
|
||||
workflowId: match.id,
|
||||
workflowName: match.name || undefined,
|
||||
folderId: match.folderId,
|
||||
})),
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: `No workflow named "${workflowName}" was found.`,
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { workflowId: workflows[0].id, workflowName: workflows[0].name || undefined }
|
||||
if (workflows.length === 1) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
workflowId: workflows[0].id,
|
||||
workflowName: workflows[0].name || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
message:
|
||||
'Multiple workflows are available. Provide workflowId or workflowName to disambiguate.',
|
||||
candidates: workflows.slice(0, 20).map((workflow) => ({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name || undefined,
|
||||
folderId: workflow.folderId,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
|
||||
|
||||
Reference in New Issue
Block a user