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 40600da75b..0c61ea0b8a 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
@@ -101,6 +101,9 @@ const ACTION_VERBS = [
'Generated',
'Rendering',
'Rendered',
+ 'Sleeping',
+ 'Slept',
+ 'Resumed',
] as const
/**
@@ -580,6 +583,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
(toolCall.state === (ClientToolCallState.executing as any) ||
toolCall.state === ('executing' as any))
+ const showWake =
+ toolCall.name === 'sleep' &&
+ (toolCall.state === (ClientToolCallState.executing as any) ||
+ toolCall.state === ('executing' as any))
+
const handleStateChange = (state: any) => {
forceUpdate({})
onStateChange?.(state)
@@ -1102,6 +1110,37 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
Move to Background
+ ) : showWake ? (
+
+
+
) : null}
)
diff --git a/apps/sim/lib/copilot/registry.ts b/apps/sim/lib/copilot/registry.ts
index 67a253cc56..db013fc546 100644
--- a/apps/sim/lib/copilot/registry.ts
+++ b/apps/sim/lib/copilot/registry.ts
@@ -33,6 +33,7 @@ export const ToolIds = z.enum([
'knowledge_base',
'manage_custom_tool',
'manage_mcp_tool',
+ 'sleep',
])
export type ToolId = z.infer
@@ -252,6 +253,14 @@ export const ToolArgSchemas = {
.optional()
.describe('Required for add and edit operations. The MCP server configuration.'),
}),
+
+ sleep: z.object({
+ seconds: z
+ .number()
+ .min(0)
+ .max(180)
+ .describe('The number of seconds to sleep (0-180, max 3 minutes)'),
+ }),
} as const
export type ToolArgSchemaMap = typeof ToolArgSchemas
@@ -318,6 +327,7 @@ export const ToolSSESchemas = {
knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool),
manage_mcp_tool: toolCallSSEFor('manage_mcp_tool', ToolArgSchemas.manage_mcp_tool),
+ sleep: toolCallSSEFor('sleep', ToolArgSchemas.sleep),
} as const
export type ToolSSESchemaMap = typeof ToolSSESchemas
@@ -552,6 +562,11 @@ export const ToolResultSchemas = {
serverName: z.string().optional(),
message: z.string().optional(),
}),
+ sleep: z.object({
+ success: z.boolean(),
+ seconds: z.number(),
+ message: z.string().optional(),
+ }),
} as const
export type ToolResultSchemaMap = typeof ToolResultSchemas
diff --git a/apps/sim/lib/copilot/tools/client/other/sleep.ts b/apps/sim/lib/copilot/tools/client/other/sleep.ts
new file mode 100644
index 0000000000..18ad084efa
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/sleep.ts
@@ -0,0 +1,144 @@
+import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
+import {
+ BaseClientTool,
+ type BaseClientToolMetadata,
+ ClientToolCallState,
+} from '@/lib/copilot/tools/client/base-tool'
+import { createLogger } from '@/lib/logs/console/logger'
+
+/** Maximum sleep duration in seconds (3 minutes) */
+const MAX_SLEEP_SECONDS = 180
+
+/** Track sleep start times for calculating elapsed time on wake */
+const sleepStartTimes: Record = {}
+
+interface SleepArgs {
+ seconds?: number
+}
+
+/**
+ * Format seconds into a human-readable duration string
+ */
+function formatDuration(seconds: number): string {
+ if (seconds >= 60) {
+ return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`
+ }
+ return `${seconds} second${seconds !== 1 ? 's' : ''}`
+}
+
+export class SleepClientTool extends BaseClientTool {
+ static readonly id = 'sleep'
+
+ constructor(toolCallId: string) {
+ super(toolCallId, SleepClientTool.id, SleepClientTool.metadata)
+ }
+
+ static readonly metadata: BaseClientToolMetadata = {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
+ [ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
+ [ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
+ [ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
+ },
+ // No interrupt - auto-execute immediately
+ getDynamicText: (params, state) => {
+ const seconds = params?.seconds
+ if (typeof seconds === 'number' && seconds > 0) {
+ const displayTime = formatDuration(seconds)
+ switch (state) {
+ case ClientToolCallState.success:
+ return `Slept for ${displayTime}`
+ case ClientToolCallState.executing:
+ case ClientToolCallState.pending:
+ return `Sleeping for ${displayTime}`
+ case ClientToolCallState.generating:
+ return `Preparing to sleep for ${displayTime}`
+ case ClientToolCallState.error:
+ return `Failed to sleep for ${displayTime}`
+ case ClientToolCallState.rejected:
+ return `Skipped sleeping for ${displayTime}`
+ case ClientToolCallState.aborted:
+ return `Aborted sleeping for ${displayTime}`
+ case ClientToolCallState.background: {
+ // Calculate elapsed time from when sleep started
+ const elapsedSeconds = params?._elapsedSeconds
+ if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) {
+ return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}`
+ }
+ return 'Resumed early'
+ }
+ }
+ }
+ return undefined
+ },
+ }
+
+ /**
+ * Get elapsed seconds since sleep started
+ */
+ getElapsedSeconds(): number {
+ const startTime = sleepStartTimes[this.toolCallId]
+ if (!startTime) return 0
+ return (Date.now() - startTime) / 1000
+ }
+
+ async handleReject(): Promise {
+ await super.handleReject()
+ this.setState(ClientToolCallState.rejected)
+ }
+
+ async handleAccept(args?: SleepArgs): Promise {
+ const logger = createLogger('SleepClientTool')
+
+ // Use a timeout slightly longer than max sleep (3 minutes + buffer)
+ const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000
+
+ await this.executeWithTimeout(async () => {
+ const params = args || {}
+ logger.debug('handleAccept() called', {
+ toolCallId: this.toolCallId,
+ state: this.getState(),
+ hasArgs: !!args,
+ seconds: params.seconds,
+ })
+
+ // Validate and clamp seconds
+ let seconds = typeof params.seconds === 'number' ? params.seconds : 0
+ if (seconds < 0) seconds = 0
+ if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS
+
+ logger.debug('Starting sleep', { seconds })
+
+ // Track start time for elapsed calculation
+ sleepStartTimes[this.toolCallId] = Date.now()
+
+ this.setState(ClientToolCallState.executing)
+
+ try {
+ // Sleep for the specified duration
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
+
+ logger.debug('Sleep completed successfully')
+ this.setState(ClientToolCallState.success)
+ await this.markToolComplete(200, `Slept for ${seconds} seconds`)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ logger.error('Sleep failed', { error: message })
+ this.setState(ClientToolCallState.error)
+ await this.markToolComplete(500, message)
+ } finally {
+ // Clean up start time tracking
+ delete sleepStartTimes[this.toolCallId]
+ }
+ }, timeoutMs)
+ }
+
+ async execute(args?: SleepArgs): Promise {
+ // Auto-execute without confirmation - go straight to executing
+ await this.handleAccept(args)
+ }
+}
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 ae2f86093e..826f5531ec 100644
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
@@ -8,6 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
@@ -850,13 +851,18 @@ function applyOperationsToWorkflowState(
* Reorder operations to ensure correct execution sequence:
* 1. delete - Remove blocks first to free up IDs and clean state
* 2. extract_from_subflow - Extract blocks from subflows before modifications
- * 3. add - Create new blocks so they exist before being referenced
+ * 3. add - Create new blocks (sorted by connection dependencies)
* 4. insert_into_subflow - Insert blocks into subflows (sorted by parent dependency)
* 5. edit - Edit existing blocks last, so connections to newly added blocks work
*
- * This ordering is CRITICAL: edit operations may reference blocks being added
- * in the same batch (e.g., connecting block A to newly added block B).
- * Without proper ordering, the target block wouldn't exist yet.
+ * This ordering is CRITICAL: operations may reference blocks being added/inserted
+ * in the same batch. Without proper ordering, target blocks wouldn't exist yet.
+ *
+ * For add operations, we use a two-pass approach:
+ * - Pass 1: Create all blocks (without connections)
+ * - Pass 2: Add all connections (now all blocks exist)
+ * This ensures that if block A connects to block B, and both are being added,
+ * B will exist when we try to create the edge from A to B.
*/
const deletes = operations.filter((op) => op.operation_type === 'delete')
const extracts = operations.filter((op) => op.operation_type === 'extract_from_subflow')
@@ -868,6 +874,8 @@ function applyOperationsToWorkflowState(
// This handles cases where a loop/parallel is being added along with its children
const sortedInserts = topologicalSortInserts(inserts, adds)
+ // We'll process add operations in two passes (handled in the switch statement below)
+ // This is tracked via a separate flag to know which pass we're in
const orderedOperations: EditWorkflowOperation[] = [
...deletes,
...extracts,
@@ -877,15 +885,46 @@ function applyOperationsToWorkflowState(
]
logger.info('Operations after reordering:', {
- order: orderedOperations.map(
+ totalOperations: orderedOperations.length,
+ deleteCount: deletes.length,
+ extractCount: extracts.length,
+ addCount: adds.length,
+ insertCount: sortedInserts.length,
+ editCount: edits.length,
+ operationOrder: orderedOperations.map(
(op) =>
`${op.operation_type}:${op.block_id}${op.params?.subflowId ? `(parent:${op.params.subflowId})` : ''}`
),
})
+ // Two-pass processing for add operations:
+ // Pass 1: Create all blocks (without connections)
+ // Pass 2: Add all connections (all blocks now exist)
+ const addOperationsWithConnections: Array<{
+ blockId: string
+ connections: Record
+ }> = []
+
for (const operation of orderedOperations) {
const { operation_type, block_id, params } = operation
+ // CRITICAL: Validate block_id is a valid string and not "undefined"
+ // This prevents undefined keys from being set in the workflow state
+ if (!isValidKey(block_id)) {
+ logSkippedItem(skippedItems, {
+ type: 'missing_required_params',
+ operationType: operation_type,
+ blockId: String(block_id || 'invalid'),
+ reason: `Invalid block_id "${block_id}" (type: ${typeof block_id}) - operation skipped. Block IDs must be valid non-empty strings.`,
+ })
+ logger.error('Invalid block_id detected in operation', {
+ operation_type,
+ block_id,
+ block_id_type: typeof block_id,
+ })
+ continue
+ }
+
logger.debug(`Executing operation: ${operation_type} for block ${block_id}`, {
params: params ? Object.keys(params) : [],
currentBlockCount: Object.keys(modifiedState.blocks).length,
@@ -1128,6 +1167,22 @@ function applyOperationsToWorkflowState(
// Add new nested blocks
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
+ // Validate childId is a valid string
+ if (!isValidKey(childId)) {
+ logSkippedItem(skippedItems, {
+ type: 'missing_required_params',
+ operationType: 'add_nested_node',
+ blockId: String(childId || 'invalid'),
+ reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`,
+ })
+ logger.error('Invalid childId detected in nestedNodes', {
+ parentBlockId: block_id,
+ childId,
+ childId_type: typeof childId,
+ })
+ return
+ }
+
const childBlockState = createBlockFromParams(
childId,
childBlock,
@@ -1360,6 +1415,22 @@ function applyOperationsToWorkflowState(
// Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) {
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
+ // Validate childId is a valid string
+ if (!isValidKey(childId)) {
+ logSkippedItem(skippedItems, {
+ type: 'missing_required_params',
+ operationType: 'add_nested_node',
+ blockId: String(childId || 'invalid'),
+ reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`,
+ })
+ logger.error('Invalid childId detected in nestedNodes', {
+ parentBlockId: block_id,
+ childId,
+ childId_type: typeof childId,
+ })
+ return
+ }
+
const childBlockState = createBlockFromParams(
childId,
childBlock,
@@ -1368,21 +1439,22 @@ function applyOperationsToWorkflowState(
)
modifiedState.blocks[childId] = childBlockState
+ // Defer connection processing to ensure all blocks exist first
if (childBlock.connections) {
- addConnectionsAsEdges(
- modifiedState,
- childId,
- childBlock.connections,
- logger,
- skippedItems
- )
+ addOperationsWithConnections.push({
+ blockId: childId,
+ connections: childBlock.connections,
+ })
}
})
}
- // Add connections as edges
+ // Defer connection processing to ensure all blocks exist first (pass 2)
if (params.connections) {
- addConnectionsAsEdges(modifiedState, block_id, params.connections, logger, skippedItems)
+ addOperationsWithConnections.push({
+ blockId: block_id,
+ connections: params.connections,
+ })
}
break
}
@@ -1506,13 +1578,18 @@ function applyOperationsToWorkflowState(
modifiedState.blocks[block_id] = newBlock
}
- // Add/update connections as edges
+ // Defer connection processing to ensure all blocks exist first
+ // This is particularly important when multiple blocks are being inserted
+ // and they have connections to each other
if (params.connections) {
- // Remove existing edges from this block
+ // Remove existing edges from this block first
modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id)
- // Add new connections
- addConnectionsAsEdges(modifiedState, block_id, params.connections, logger, skippedItems)
+ // Add to deferred connections list
+ addOperationsWithConnections.push({
+ blockId: block_id,
+ connections: params.connections,
+ })
}
break
}
@@ -1562,6 +1639,34 @@ function applyOperationsToWorkflowState(
}
}
+ // Pass 2: Add all deferred connections from add/insert operations
+ // Now all blocks exist (from add, insert, and edit operations), so connections can be safely created
+ // This ensures that if block A connects to block B, and both are being added/inserted,
+ // B will exist when we create the edge from A to B
+ if (addOperationsWithConnections.length > 0) {
+ logger.info('Processing deferred connections from add/insert operations', {
+ deferredConnectionCount: addOperationsWithConnections.length,
+ totalBlocks: Object.keys(modifiedState.blocks).length,
+ })
+
+ for (const { blockId, connections } of addOperationsWithConnections) {
+ // Verify the source block still exists (it might have been deleted by a later operation)
+ if (!modifiedState.blocks[blockId]) {
+ logger.warn('Source block no longer exists for deferred connection', {
+ blockId,
+ availableBlocks: Object.keys(modifiedState.blocks),
+ })
+ continue
+ }
+
+ addConnectionsAsEdges(modifiedState, blockId, connections, logger, skippedItems)
+ }
+
+ logger.info('Finished processing deferred connections', {
+ totalEdges: modifiedState.edges.length,
+ })
+ }
+
// Regenerate loops and parallels after modifications
modifiedState.loops = generateLoopBlocks(modifiedState.blocks)
modifiedState.parallels = generateParallelBlocks(modifiedState.blocks)
diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts
index 292edbb628..e41652acdb 100644
--- a/apps/sim/lib/workflows/diff/diff-engine.ts
+++ b/apps/sim/lib/workflows/diff/diff-engine.ts
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockWithDiff } from '@/lib/workflows/diff/types'
+import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -537,6 +538,17 @@ export class WorkflowDiffEngine {
// First pass: build ID mappings
for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) {
+ // CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state
+ if (!isValidKey(proposedId)) {
+ logger.error('Invalid proposedId detected in proposed state', {
+ proposedId,
+ proposedId_type: typeof proposedId,
+ blockType: proposedBlock?.type,
+ blockName: proposedBlock?.name,
+ })
+ continue
+ }
+
const key = `${proposedBlock.type}:${proposedBlock.name}`
// Check if this block exists in current state by type:name
@@ -552,7 +564,31 @@ export class WorkflowDiffEngine {
// Second pass: build final blocks with mapped IDs
for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) {
+ // CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state
+ if (!isValidKey(proposedId)) {
+ logger.error('Invalid proposedId detected in proposed state (second pass)', {
+ proposedId,
+ proposedId_type: typeof proposedId,
+ blockType: proposedBlock?.type,
+ blockName: proposedBlock?.name,
+ })
+ continue
+ }
+
const finalId = idMap[proposedId]
+
+ // CRITICAL: Validate finalId before using as key
+ if (!isValidKey(finalId)) {
+ logger.error('Invalid finalId generated from idMap', {
+ proposedId,
+ finalId,
+ finalId_type: typeof finalId,
+ blockType: proposedBlock?.type,
+ blockName: proposedBlock?.name,
+ })
+ continue
+ }
+
const key = `${proposedBlock.type}:${proposedBlock.name}`
const existingBlock = existingBlockMap[key]?.block
@@ -617,6 +653,8 @@ export class WorkflowDiffEngine {
const { generateLoopBlocks, generateParallelBlocks } = await import(
'@/stores/workflows/workflow/utils'
)
+
+ // Build the proposed state
const finalProposedState: WorkflowState = {
blocks: finalBlocks,
edges: finalEdges,
@@ -625,6 +663,9 @@ export class WorkflowDiffEngine {
lastSaved: Date.now(),
}
+ // Use the proposed state directly - validation happens at the source
+ const fullyCleanedState = finalProposedState
+
// Transfer block heights from baseline workflow for better measurements in diff view
// If editing on top of diff, this transfers from the diff (which already has good heights)
// Otherwise transfers from original workflow
@@ -694,7 +735,7 @@ export class WorkflowDiffEngine {
'@/lib/workflows/autolayout/constants'
)
- const layoutedBlocks = applyTargetedLayout(finalBlocks, finalProposedState.edges, {
+ const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, {
changedBlockIds: impactedBlockArray,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
@@ -742,7 +783,7 @@ export class WorkflowDiffEngine {
const layoutResult = applyNativeAutoLayout(
finalBlocks,
- finalProposedState.edges,
+ fullyCleanedState.edges,
DEFAULT_LAYOUT_OPTIONS
)
@@ -824,7 +865,7 @@ export class WorkflowDiffEngine {
})
// Create edge identifiers for proposed state
- finalEdges.forEach((edge) => {
+ fullyCleanedState.edges.forEach((edge) => {
const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}`
proposedEdgeSet.add(edgeId)
})
@@ -863,21 +904,21 @@ export class WorkflowDiffEngine {
}
}
- // Apply diff markers to blocks
+ // Apply diff markers to blocks in the fully cleaned state
if (computed) {
for (const id of computed.new_blocks || []) {
- if (finalBlocks[id]) {
- finalBlocks[id].is_diff = 'new'
+ if (fullyCleanedState.blocks[id]) {
+ ;(fullyCleanedState.blocks[id] as any).is_diff = 'new'
}
}
for (const id of computed.edited_blocks || []) {
- if (finalBlocks[id]) {
- finalBlocks[id].is_diff = 'edited'
+ if (fullyCleanedState.blocks[id]) {
+ ;(fullyCleanedState.blocks[id] as any).is_diff = 'edited'
// Also mark specific subblocks that changed
if (computed.field_diffs?.[id]) {
const fieldDiff = computed.field_diffs[id]
- const block = finalBlocks[id]
+ const block = fullyCleanedState.blocks[id]
// Apply diff markers to changed subblocks
for (const changedField of fieldDiff.changed_fields) {
@@ -889,12 +930,12 @@ export class WorkflowDiffEngine {
}
}
}
- // Note: We don't remove deleted blocks from finalBlocks, just mark them
+ // Note: We don't remove deleted blocks from fullyCleanedState, just mark them
}
- // Store the diff
+ // Store the diff with the fully sanitized state
this.currentDiff = {
- proposedState: finalProposedState,
+ proposedState: fullyCleanedState,
diffAnalysis: computed,
metadata: {
source: 'workflow_state',
@@ -903,10 +944,10 @@ export class WorkflowDiffEngine {
}
logger.info('Successfully created diff from workflow state', {
- blockCount: Object.keys(finalProposedState.blocks).length,
- edgeCount: finalProposedState.edges.length,
- hasLoops: Object.keys(finalProposedState.loops || {}).length > 0,
- hasParallels: Object.keys(finalProposedState.parallels || {}).length > 0,
+ blockCount: Object.keys(fullyCleanedState.blocks).length,
+ edgeCount: fullyCleanedState.edges.length,
+ hasLoops: Object.keys(fullyCleanedState.loops || {}).length > 0,
+ hasParallels: Object.keys(fullyCleanedState.parallels || {}).length > 0,
newBlocks: computed?.new_blocks?.length || 0,
editedBlocks: computed?.edited_blocks?.length || 0,
deletedBlocks: computed?.deleted_blocks?.length || 0,
@@ -1096,6 +1137,17 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
const cleanBlocks: Record = {}
for (const [blockId, block] of Object.entries(state.blocks || {})) {
+ // Validate block ID at the source - skip invalid IDs
+ if (!isValidKey(blockId)) {
+ logger.error('Invalid blockId detected in stripWorkflowDiffMarkers', {
+ blockId,
+ blockId_type: typeof blockId,
+ blockType: block?.type,
+ blockName: block?.name,
+ })
+ continue
+ }
+
const cleanBlock: BlockState = structuredClone(block)
const blockWithDiff = cleanBlock as BlockState & BlockWithDiff
blockWithDiff.is_diff = undefined
diff --git a/apps/sim/lib/workflows/sanitization/key-validation.ts b/apps/sim/lib/workflows/sanitization/key-validation.ts
new file mode 100644
index 0000000000..ff66166648
--- /dev/null
+++ b/apps/sim/lib/workflows/sanitization/key-validation.ts
@@ -0,0 +1,9 @@
+/**
+ * Checks if a key is valid (not undefined, null, empty, or literal "undefined"/"null")
+ * Use this to validate BEFORE setting a dynamic key on any object.
+ */
+export function isValidKey(key: unknown): key is string {
+ return (
+ !!key && typeof key === 'string' && key !== 'undefined' && key !== 'null' && key.trim() !== ''
+ )
+}
diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts
index 450844ea49..a350fb2d88 100644
--- a/apps/sim/stores/panel/copilot/store.ts
+++ b/apps/sim/stores/panel/copilot/store.ts
@@ -32,6 +32,7 @@ import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
+import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
import { createExecutionContext, getTool } from '@/lib/copilot/tools/client/registry'
import { GetCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-credentials'
import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables'
@@ -104,6 +105,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = {
navigate_ui: (id) => new NavigateUIClientTool(id),
manage_custom_tool: (id) => new ManageCustomToolClientTool(id),
manage_mcp_tool: (id) => new ManageMcpToolClientTool(id),
+ sleep: (id) => new SleepClientTool(id),
}
// Read-only static metadata for class-based tools (no instances)
@@ -141,6 +143,7 @@ export const CLASS_TOOL_METADATA: Record()(
set({ toolCallsById: map })
} catch {}
},
+
+ updateToolCallParams: (toolCallId: string, params: Record) => {
+ try {
+ if (!toolCallId) return
+ const map = { ...get().toolCallsById }
+ const current = map[toolCallId]
+ if (!current) return
+ const updatedParams = { ...current.params, ...params }
+ map[toolCallId] = {
+ ...current,
+ params: updatedParams,
+ display: resolveToolDisplay(current.name, current.state, toolCallId, updatedParams),
+ }
+ set({ toolCallsById: map })
+ } catch {}
+ },
updatePreviewToolCallState: (
toolCallState: 'accepted' | 'rejected' | 'error',
toolCallId?: string
diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts
index 89f27efa54..2380aca2b1 100644
--- a/apps/sim/stores/panel/copilot/types.ts
+++ b/apps/sim/stores/panel/copilot/types.ts
@@ -178,6 +178,7 @@ export interface CopilotActions {
toolCallId?: string
) => void
setToolCallState: (toolCall: any, newState: ClientToolCallState, options?: any) => void
+ updateToolCallParams: (toolCallId: string, params: Record) => void
sendDocsMessage: (query: string, options?: { stream?: boolean; topK?: number }) => Promise
saveChatMessages: (chatId: string) => Promise