diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index bba129df70..0992aaaea0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store' import type { SubAgentContentBlock } from '@/stores/panel/copilot/types' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -/** - * Parse special tags from content - */ /** * Plan step can be either a string or an object with title and plan */ @@ -47,6 +44,56 @@ interface ParsedTags { cleanContent: string } +/** + * Extract plan steps from plan_respond tool calls in subagent blocks. + * Returns { steps, isComplete } where steps is in the format expected by PlanSteps component. + */ +function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): { + steps: Record | undefined + isComplete: boolean +} { + if (!blocks) return { steps: undefined, isComplete: false } + + // Find the plan_respond tool call + const planRespondBlock = blocks.find( + (b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond' + ) + + if (!planRespondBlock?.toolCall) { + return { steps: undefined, isComplete: false } + } + + // Tool call arguments can be in different places depending on the source + // Also handle nested data.arguments structure from the schema + const tc = planRespondBlock.toolCall as any + const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {} + const stepsArray = args.steps + + if (!Array.isArray(stepsArray) || stepsArray.length === 0) { + return { steps: undefined, isComplete: false } + } + + // Convert array format to Record format + // From: [{ number: 1, title: "..." }, { number: 2, title: "..." }] + // To: { "1": "...", "2": "..." } + const steps: Record = {} + for (const step of stepsArray) { + if (step.number !== undefined && step.title) { + steps[String(step.number)] = step.title + } + } + + // Check if the tool call is complete (not pending/executing) + const isComplete = + planRespondBlock.toolCall.state === ClientToolCallState.success || + planRespondBlock.toolCall.state === ClientToolCallState.error + + return { + steps: Object.keys(steps).length > 0 ? steps : undefined, + isComplete, + } +} + /** * Try to parse partial JSON for streaming options. * Attempts to extract complete key-value pairs from incomplete JSON. @@ -654,11 +701,20 @@ function SubAgentThinkingContent({ } } + // Extract plan from plan_respond tool call (preferred) or fall back to tags + const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks) const allParsed = parseSpecialTags(allRawText) - if (!cleanText.trim() && !allParsed.plan) return null + // Prefer plan_respond tool data over tags + const hasPlan = + !!(planSteps && Object.keys(planSteps).length > 0) || + !!(allParsed.plan && Object.keys(allParsed.plan).length > 0) + const planToRender = planSteps || allParsed.plan + const isPlanStreaming = planSteps ? !planComplete : isStreaming - const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0) + if (!cleanText.trim() && !hasPlan) return null + + const hasSpecialTags = hasPlan return (
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({ hasSpecialTags={hasSpecialTags} /> )} - {allParsed.plan && Object.keys(allParsed.plan).length > 0 && ( - - )} + {hasPlan && planToRender && }
) } @@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ } const allParsed = parseSpecialTags(allRawText) + + // Extract plan from plan_respond tool call (preferred) or fall back to tags + const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks( + toolCall.subAgentBlocks + ) + const hasPlan = + !!(planSteps && Object.keys(planSteps).length > 0) || + !!(allParsed.plan && Object.keys(allParsed.plan).length > 0) + const planToRender = planSteps || allParsed.plan + const isPlanStreaming = planSteps ? !planComplete : isStreaming + const hasSpecialTags = !!( - (allParsed.plan && Object.keys(allParsed.plan).length > 0) || + hasPlan || (allParsed.options && Object.keys(allParsed.options).length > 0) ) @@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ const outerLabel = getSubagentCompletionLabel(toolCall.name) const durationText = `${outerLabel} for ${formatDuration(duration)}` - const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0 - const renderCollapsibleContent = () => ( <> {segments.map((segment, index) => { @@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ return (
{renderCollapsibleContent()} - {hasPlan && } + {hasPlan && planToRender && }
) } @@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ {/* Plan stays outside the collapsible */} - {hasPlan && } + {hasPlan && planToRender && } ) }) @@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: if ( toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress' || - toolCall.name === 'tool_search_tool_regex' + toolCall.name === 'tool_search_tool_regex' || + toolCall.name === 'user_memory' || + toolCall.name === 'edit_responsd' || + toolCall.name === 'debug_respond' || + toolCall.name === 'plan_respond' ) return null diff --git a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts index 06b36a2b88..63f4c6c6f4 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts @@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool { } } - const variablesArray = Object.values(byName) + // Convert byName (keyed by name) to record keyed by ID for the API + const variablesRecord: Record = {} + for (const v of Object.values(byName)) { + variablesRecord[v.id] = v + } - // POST full variables array to persist + // POST full variables record to persist const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesArray }), + body: JSON.stringify({ variables: variablesRecord }), }) if (!res.ok) { const txt = await res.text().catch(() => '') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index fb1598fc20..60136d1a8c 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string { interface EdgeHandleValidationResult { valid: boolean error?: string + /** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */ + normalizedHandle?: string } /** @@ -851,13 +853,6 @@ function validateSourceHandleForBlock( } case 'condition': { - if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) { - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`, - } - } - const conditionsValue = sourceBlock?.subBlocks?.conditions?.value if (!conditionsValue) { return { @@ -866,6 +861,8 @@ function validateSourceHandleForBlock( } } + // validateConditionHandle accepts simple format (if, else-if-0, else), + // legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid}) return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue) } @@ -879,13 +876,6 @@ function validateSourceHandleForBlock( } case 'router_v2': { - if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) { - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`, - } - } - const routesValue = sourceBlock?.subBlocks?.routes?.value if (!routesValue) { return { @@ -894,6 +884,8 @@ function validateSourceHandleForBlock( } } + // validateRouterHandle accepts simple format (route-0, route-1), + // legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid}) return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue) } @@ -910,7 +902,12 @@ function validateSourceHandleForBlock( /** * Validates condition handle references a valid condition in the block. - * Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if) + * Accepts multiple formats: + * - Simple format: "if", "else-if-0", "else-if-1", "else" + * - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if" + * - Internal ID format: "condition-{conditionId}" + * + * Returns the normalized handle (condition-{conditionId}) for storage. */ function validateConditionHandle( sourceHandle: string, @@ -943,48 +940,80 @@ function validateConditionHandle( } } - const validHandles = new Set() - const semanticPrefix = `condition-${blockId}-` - let elseIfCount = 0 + // Build a map of all valid handle formats -> normalized handle (condition-{conditionId}) + const handleToNormalized = new Map() + const legacySemanticPrefix = `condition-${blockId}-` + let elseIfIndex = 0 for (const condition of conditions) { - if (condition.id) { - validHandles.add(`condition-${condition.id}`) - } + if (!condition.id) continue + const normalizedHandle = `condition-${condition.id}` const title = condition.title?.toLowerCase() + + // Always accept internal ID format + handleToNormalized.set(normalizedHandle, normalizedHandle) + if (title === 'if') { - validHandles.add(`${semanticPrefix}if`) + // Simple format: "if" + handleToNormalized.set('if', normalizedHandle) + // Legacy format: "condition-{blockId}-if" + handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle) } else if (title === 'else if') { - elseIfCount++ - validHandles.add( - elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}` - ) + // Simple format: "else-if-0", "else-if-1", etc. (0-indexed) + handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle) + // Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second + if (elseIfIndex === 0) { + handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle) + } else { + handleToNormalized.set( + `${legacySemanticPrefix}else-if-${elseIfIndex + 1}`, + normalizedHandle + ) + } + elseIfIndex++ } else if (title === 'else') { - validHandles.add(`${semanticPrefix}else`) + // Simple format: "else" + handleToNormalized.set('else', normalizedHandle) + // Legacy format: "condition-{blockId}-else" + handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle) } } - if (validHandles.has(sourceHandle)) { - return { valid: true } + const normalizedHandle = handleToNormalized.get(sourceHandle) + if (normalizedHandle) { + return { valid: true, normalizedHandle } } - const validOptions = Array.from(validHandles).slice(0, 5) - const moreCount = validHandles.size - validOptions.length - let validOptionsStr = validOptions.join(', ') - if (moreCount > 0) { - validOptionsStr += `, ... and ${moreCount} more` + // Build list of valid simple format options for error message + const simpleOptions: string[] = [] + elseIfIndex = 0 + for (const condition of conditions) { + const title = condition.title?.toLowerCase() + if (title === 'if') { + simpleOptions.push('if') + } else if (title === 'else if') { + simpleOptions.push(`else-if-${elseIfIndex}`) + elseIfIndex++ + } else if (title === 'else') { + simpleOptions.push('else') + } } return { valid: false, - error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`, + error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, } } /** * Validates router handle references a valid route in the block. - * Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1) + * Accepts multiple formats: + * - Simple format: "route-0", "route-1", "route-2" (0-indexed) + * - Legacy semantic format: "router-{blockId}-route-1" (1-indexed) + * - Internal ID format: "router-{routeId}" + * + * Returns the normalized handle (router-{routeId}) for storage. */ function validateRouterHandle( sourceHandle: string, @@ -1017,47 +1046,48 @@ function validateRouterHandle( } } - const validHandles = new Set() - const semanticPrefix = `router-${blockId}-` + // Build a map of all valid handle formats -> normalized handle (router-{routeId}) + const handleToNormalized = new Map() + const legacySemanticPrefix = `router-${blockId}-` for (let i = 0; i < routes.length; i++) { const route = routes[i] + if (!route.id) continue - // Accept internal ID format: router-{uuid} - if (route.id) { - validHandles.add(`router-${route.id}`) - } + const normalizedHandle = `router-${route.id}` + + // Always accept internal ID format: router-{uuid} + handleToNormalized.set(normalizedHandle, normalizedHandle) - // Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc. - validHandles.add(`${semanticPrefix}route-${i + 1}`) + // Simple format: route-0, route-1, etc. (0-indexed) + handleToNormalized.set(`route-${i}`, normalizedHandle) + + // Legacy 1-indexed route number format: router-{blockId}-route-1 + handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle) // Accept normalized title format: router-{blockId}-{normalized-title} - // Normalize: lowercase, replace spaces with dashes, remove special chars if (route.title && typeof route.title === 'string') { const normalizedTitle = route.title .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') if (normalizedTitle) { - validHandles.add(`${semanticPrefix}${normalizedTitle}`) + handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle) } } } - if (validHandles.has(sourceHandle)) { - return { valid: true } + const normalizedHandle = handleToNormalized.get(sourceHandle) + if (normalizedHandle) { + return { valid: true, normalizedHandle } } - const validOptions = Array.from(validHandles).slice(0, 5) - const moreCount = validHandles.size - validOptions.length - let validOptionsStr = validOptions.join(', ') - if (moreCount > 0) { - validOptionsStr += `, ... and ${moreCount} more` - } + // Build list of valid simple format options for error message + const simpleOptions = routes.map((_, i) => `route-${i}`) return { valid: false, - error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`, + error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, } } @@ -1172,10 +1202,13 @@ function createValidatedEdge( return false } + // Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}') + const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle + modifiedState.edges.push({ id: crypto.randomUUID(), source: sourceBlockId, - sourceHandle, + sourceHandle: finalSourceHandle, target: targetBlockId, targetHandle, type: 'default', @@ -1184,7 +1217,11 @@ function createValidatedEdge( } /** - * Adds connections as edges for a block + * Adds connections as edges for a block. + * Supports multiple target formats: + * - String: "target-block-id" + * - Object: { block: "target-block-id", handle?: "custom-target-handle" } + * - Array of strings or objects */ function addConnectionsAsEdges( modifiedState: any, @@ -1194,19 +1231,34 @@ function addConnectionsAsEdges( skippedItems?: SkippedItem[] ): void { Object.entries(connections).forEach(([sourceHandle, targets]) => { - const targetArray = Array.isArray(targets) ? targets : [targets] - targetArray.forEach((targetId: string) => { + if (targets === null) return + + const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => { createValidatedEdge( modifiedState, blockId, - targetId, + targetBlock, sourceHandle, - 'target', + targetHandle || 'target', 'add_edge', logger, skippedItems ) - }) + } + + if (typeof targets === 'string') { + addEdgeForTarget(targets) + } else if (Array.isArray(targets)) { + targets.forEach((target: any) => { + if (typeof target === 'string') { + addEdgeForTarget(target) + } else if (target?.block) { + addEdgeForTarget(target.block, target.handle) + } + }) + } else if (typeof targets === 'object' && targets?.block) { + addEdgeForTarget(targets.block, targets.handle) + } }) } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index d67b2cd34b..0e452ccd1d 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -269,11 +269,12 @@ function sanitizeSubBlocks( } /** - * Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if) + * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) + * Uses 0-indexed numbering for else-if conditions */ -function convertConditionHandleToSemantic( +function convertConditionHandleToSimple( handle: string, - blockId: string, + _blockId: string, block: BlockState ): string { if (!handle.startsWith('condition-')) { @@ -300,27 +301,24 @@ function convertConditionHandleToSemantic( return handle } - // Find the condition by ID and generate semantic handle - let elseIfCount = 0 + // Find the condition by ID and generate simple handle + let elseIfIndex = 0 for (const condition of conditions) { const title = condition.title?.toLowerCase() if (condition.id === conditionId) { if (title === 'if') { - return `condition-${blockId}-if` + return 'if' } if (title === 'else if') { - elseIfCount++ - return elseIfCount === 1 - ? `condition-${blockId}-else-if` - : `condition-${blockId}-else-if-${elseIfCount}` + return `else-if-${elseIfIndex}` } if (title === 'else') { - return `condition-${blockId}-else` + return 'else' } } - // Count else-ifs as we iterate + // Count else-ifs as we iterate (for index tracking) if (title === 'else if') { - elseIfCount++ + elseIfIndex++ } } @@ -329,9 +327,10 @@ function convertConditionHandleToSemantic( } /** - * Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N) + * Convert internal router handle (router-{uuid}) to simple format (route-0, route-1) + * Uses 0-indexed numbering for routes */ -function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string { +function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string { if (!handle.startsWith('router-')) { return handle } @@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B return handle } - // Find the route by ID and generate semantic handle (1-indexed) + // Find the route by ID and generate simple handle (0-indexed) for (let i = 0; i < routes.length; i++) { if (routes[i].id === routeId) { - return `router-${blockId}-route-${i + 1}` + return `route-${i}` } } @@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B } /** - * Convert source handle to semantic format for condition and router blocks + * Convert source handle to simple format for condition and router blocks + * Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers) */ -function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string { +function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string { if (handle.startsWith('condition-') && block.type === 'condition') { - return convertConditionHandleToSemantic(handle, blockId, block) + return convertConditionHandleToSimple(handle, blockId, block) } if (handle.startsWith('router-') && block.type === 'router_v2') { - return convertRouterHandleToSemantic(handle, blockId, block) + return convertRouterHandleToSimple(handle, blockId, block) } return handle @@ -400,12 +400,12 @@ function extractConnectionsForBlock( return undefined } - // Group by source handle (converting to semantic format) + // Group by source handle (converting to simple format) for (const edge of outgoingEdges) { let handle = edge.sourceHandle || 'source' - // Convert internal UUID handles to semantic format - handle = convertToSemanticHandle(handle, blockId, block) + // Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.) + handle = convertToSimpleHandle(handle, blockId, block) if (!connections[handle]) { connections[handle] = []