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 0791b4a03..3b7ade185 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 @@ -1221,13 +1221,26 @@ function isIntegrationTool(toolName: string): boolean { } function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { + if (!toolCall.name || toolCall.name === 'unknown_tool') { + return false + } + + if (toolCall.state !== ClientToolCallState.pending) { + return false + } + + // Never show buttons for tools the user has marked as always-allowed + if (useCopilotStore.getState().autoAllowedTools.includes(toolCall.name)) { + return false + } + const hasInterrupt = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt === true - if (hasInterrupt && toolCall.state === 'pending') { + if (hasInterrupt) { return true } const mode = useCopilotStore.getState().mode - if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') { + if (mode === 'build' && isIntegrationTool(toolCall.name)) { return true } diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts index ddfa69fb3..4dcd2dc2b 100644 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ b/apps/sim/lib/copilot/client-sse/handlers.ts @@ -10,8 +10,8 @@ import { } from '@/lib/copilot/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import type { CopilotStore, CopilotStreamInfo, CopilotToolCall } from '@/stores/panel/copilot/types' -import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useVariablesStore } from '@/stores/panel/variables/store' +import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -499,7 +499,10 @@ export const sseHandlers: Record = { const { toolCallsById } = get() if (!toolCallsById[toolCallId]) { - const initialState = ClientToolCallState.pending + const isAutoAllowed = get().autoAllowedTools.includes(toolName) + const initialState = isAutoAllowed + ? ClientToolCallState.executing + : ClientToolCallState.pending const tc: CopilotToolCall = { id: toolCallId, name: toolName, @@ -524,23 +527,39 @@ export const sseHandlers: Record = { const { toolCallsById } = get() const existing = toolCallsById[id] + const toolName = name || existing?.name || 'unknown_tool' + const autoAllowedTools = get().autoAllowedTools + const isAutoAllowed = + autoAllowedTools.includes(toolName) || + (existing?.name ? autoAllowedTools.includes(existing.name) : false) + let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending + + // Avoid flickering back to pending on partial/duplicate events once a tool is executing. + if ( + existing?.state === ClientToolCallState.executing && + initialState === ClientToolCallState.pending + ) { + initialState = ClientToolCallState.executing + } + const next: CopilotToolCall = existing ? { ...existing, - state: ClientToolCallState.pending, + name: toolName, + state: initialState, ...(args ? { params: args } : {}), - display: resolveToolDisplay(name, ClientToolCallState.pending, id, args), + display: resolveToolDisplay(toolName, initialState, id, args || existing.params), } : { id, - name: name || 'unknown_tool', - state: ClientToolCallState.pending, + name: toolName, + state: initialState, ...(args ? { params: args } : {}), - display: resolveToolDisplay(name, ClientToolCallState.pending, id, args), + display: resolveToolDisplay(toolName, initialState, id, args), } const updated = { ...toolCallsById, [id]: next } set({ toolCallsById: updated }) - logger.info('[toolCallsById] → pending', { id, name, params: args }) + logger.info(`[toolCallsById] → ${initialState}`, { id, name: toolName, params: args }) upsertToolCallBlock(context, next) updateStreamingMessage(set, context) @@ -550,7 +569,7 @@ export const sseHandlers: Record = { } // OAuth: dispatch event to open the OAuth connect modal - if (name === 'oauth_request_access' && args && typeof window !== 'undefined') { + if (toolName === 'oauth_request_access' && args && typeof window !== 'undefined') { try { window.dispatchEvent( new CustomEvent('open-oauth-connect', { diff --git a/apps/sim/lib/copilot/client-sse/subagent-handlers.ts b/apps/sim/lib/copilot/client-sse/subagent-handlers.ts index 98abb45e4..a974ab4c8 100644 --- a/apps/sim/lib/copilot/client-sse/subagent-handlers.ts +++ b/apps/sim/lib/copilot/client-sse/subagent-handlers.ts @@ -190,12 +190,27 @@ export const subAgentSSEHandlers: Record = { const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex( (tc: CopilotToolCall) => tc.id === id ) + const existingToolCall = + existingIndex >= 0 ? context.subAgentToolCalls[parentToolCallId][existingIndex] : undefined + + // Auto-allowed tools skip pending state to avoid flashing interrupt buttons + const isAutoAllowed = get().autoAllowedTools.includes(name) + let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending + + // Avoid flickering back to pending on partial/duplicate events once a tool is executing. + if ( + existingToolCall?.state === ClientToolCallState.executing && + initialState === ClientToolCallState.pending + ) { + initialState = ClientToolCallState.executing + } + const subAgentToolCall: CopilotToolCall = { id, name, - state: ClientToolCallState.pending, + state: initialState, ...(args ? { params: args } : {}), - display: resolveToolDisplay(name, ClientToolCallState.pending, id, args), + display: resolveToolDisplay(name, initialState, id, args), } if (existingIndex >= 0) { @@ -231,14 +246,11 @@ export const subAgentSSEHandlers: Record = { // infer from presence of result data vs error (same logic as server-side // inferToolSuccess). The Go backend uses `*bool` with omitempty so // `success` is present when explicitly set, and absent for non-tool events. - const hasExplicitSuccess = - data?.success !== undefined || resultData.success !== undefined + const hasExplicitSuccess = data?.success !== undefined || resultData.success !== undefined const explicitSuccess = data?.success ?? resultData.success const hasResultData = data?.result !== undefined || resultData.result !== undefined const hasError = !!data?.error || !!resultData.error - const success: boolean = hasExplicitSuccess - ? !!explicitSuccess - : hasResultData && !hasError + const success: boolean = hasExplicitSuccess ? !!explicitSuccess : hasResultData && !hasError if (!toolCallId) return if (!context.subAgentToolCalls[parentToolCallId]) return diff --git a/apps/sim/lib/copilot/messages/serialization.ts b/apps/sim/lib/copilot/messages/serialization.ts index bcc58e0cf..29686f6bc 100644 --- a/apps/sim/lib/copilot/messages/serialization.ts +++ b/apps/sim/lib/copilot/messages/serialization.ts @@ -1,14 +1,42 @@ import { createLogger } from '@sim/logger' +import { resolveToolDisplay } from '@/lib/copilot/store-utils' +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import type { CopilotMessage, CopilotToolCall } from '@/stores/panel/copilot/types' import { maskCredentialIdsInValue } from './credential-masking' const logger = createLogger('CopilotMessageSerialization') +const TERMINAL_STATES = new Set([ + ClientToolCallState.success, + ClientToolCallState.error, + ClientToolCallState.rejected, + ClientToolCallState.aborted, + ClientToolCallState.review, + ClientToolCallState.background, +]) + +/** + * Clears streaming flags and normalizes non-terminal tool call states to 'aborted'. + * This ensures that tool calls loaded from DB after a refresh/abort don't render + * as in-progress with shimmer animations or interrupt buttons. + */ export function clearStreamingFlags(toolCall: CopilotToolCall): void { if (!toolCall) return toolCall.subAgentStreaming = false + // Normalize non-terminal states when loading from DB. + // 'executing' → 'success': the server was running it, assume it completed. + // 'pending'/'generating' → 'aborted': never reached execution. + if (toolCall.state && !TERMINAL_STATES.has(toolCall.state)) { + const normalized = + toolCall.state === ClientToolCallState.executing + ? ClientToolCallState.success + : ClientToolCallState.aborted + toolCall.state = normalized + toolCall.display = resolveToolDisplay(toolCall.name, normalized, toolCall.id, toolCall.params) + } + if (Array.isArray(toolCall.subAgentBlocks)) { for (const block of toolCall.subAgentBlocks) { if (block?.type === 'subagent_tool_call' && block.toolCall) {