From ac26aa4e4c7cc2d493f136b59738c0708803cc6d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 21 May 2026 15:25:25 +0200 Subject: [PATCH] feat: add yolo mode for ai chat tools (#9258) * feat: add yolo mode for ai chat tools * nit * fix: align chat footer controls * feat: add ai chat autonomy modes * feat: add autonomy mode dropdown * fix: highlight yolo autonomy icon * fix: auto accept flow edits * fix: hide unsupported autonomy modes * fix: handle auto-accept flow editor races --- .../copilot/chat/AIChatDisplay.svelte | 265 ++++++++++++++---- .../copilot/chat/AIChatInlineWidget.svelte | 2 +- .../copilot/chat/AIChatManager.svelte.ts | 144 +++++++++- .../copilot/chat/AIChatManager.test.ts | 155 ++++++++++ .../copilot/chat/flow/FlowAIChat.svelte | 21 +- .../copilot/chat/script/CodeDisplay.svelte | 2 +- .../components/copilot/chat/shared.test.ts | 43 +++ .../src/lib/components/copilot/chat/shared.ts | 8 +- 8 files changed, 573 insertions(+), 67 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/AIChatManager.test.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 5967a75d30..e689c3e6c0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -4,7 +4,10 @@ import AvailableContextList from './AvailableContextList.svelte' import { type Snippet } from 'svelte' import { + AlertTriangle, ArrowDown, + ChevronDown, + ChevronsRight, CheckIcon, HistoryIcon, Hourglass, @@ -23,16 +26,43 @@ import ProviderModelSelector from './ProviderModelSelector.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' - import { AIMode } from './AIChatManager.svelte' + import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' import type { SelectedContext } from './app/core' + const MAX_YOLO_TOOLTIP_TOOLS = 8 const aiChatManager = getAiChatManager() + type AutonomyModeOption = { label: string; mode: AIAutonomyMode } + const autonomyModeOptions: AutonomyModeOption[] = [ + { label: 'auto accept off', mode: AIAutonomyMode.DEFAULT }, + { label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT }, + { label: 'yolo on', mode: AIAutonomyMode.YOLO } + ] + const autonomyModeLabel = ( + mode: AIAutonomyMode, + options: AutonomyModeOption[] = autonomyModeOptions + ) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label + const isAutonomyModeAvailable = ( + mode: AIAutonomyMode, + autoAcceptEditsAvailable: boolean, + autoAcceptToolConfirmationsAvailable: boolean + ) => { + switch (mode) { + case AIAutonomyMode.DEFAULT: + return true + case AIAutonomyMode.ACCEPT_EDIT: + return autoAcceptEditsAvailable + case AIAutonomyMode.YOLO: + return autoAcceptToolConfirmationsAvailable + } + return false + } let { messages, @@ -179,6 +209,37 @@ aiChatManager.mode === AIMode.GLOBAL || aiChatManager.mode === AIMode.APP ) + const availableAutonomyModeOptions = $derived.by(() => + autonomyModeOptions.filter((option) => + isAutonomyModeAvailable( + option.mode, + aiChatManager.autoAcceptEditsAvailable, + aiChatManager.autoAcceptToolConfirmationsAvailable + ) + ) + ) + const effectiveAutonomyMode = $derived( + availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) + ? aiChatManager.autonomyMode + : AIAutonomyMode.DEFAULT + ) + const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) + const autonomyModeTooltip = $derived.by(() => { + switch (effectiveAutonomyMode) { + case AIAutonomyMode.ACCEPT_EDIT: + return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.' + case AIAutonomyMode.YOLO: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Automatically accepts tool confirmations.' + } + return 'Automatically accepts script and flow edits plus tool confirmations.' + default: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Requires confirmation for tool calls.' + } + return 'Requires confirmation for edits and tool calls.' + } + }) // "Waiting for user" detection — when the latest tool message is staged // for confirmation or has an unanswered askUserQuestion, the AI loop is @@ -209,6 +270,29 @@ } return aiChatManager.appAiChatHelpers.getSelectedContext() }) + + const yoloBypassedTools = $derived.by(() => { + return aiChatManager.tools + .filter((tool) => tool.requiresConfirmation === true) + .map((tool) => ({ + name: tool.def.function.name, + label: tool.confirmationMessage ?? tool.def.function.name + })) + }) + const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS)) + const hiddenYoloBypassedToolCount = $derived( + Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length) + ) + const showFlowPendingActionControls = $derived( + (aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) && + !aiChatManager.autoAcceptEditsActive + ) + const showFooterLeftControls = $derived( + !disabled && + (showContextPicker || + showAutonomyModeSelector || + (aiChatManager.mode === AIMode.SCRIPT && hasDiff)) + )
@@ -322,7 +406,7 @@
{#if waitingForUserAction} @@ -345,7 +429,7 @@ transition:fade={{ duration: 120 }} class={twMerge( 'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md', - aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2' + showFlowPendingActionControls ? 'bottom-12' : 'bottom-2' )} > + {/each} +
+ {/snippet} + +
+ {/if} + {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable} + + + {#snippet text()} +
+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'Yolo auto-accepts edits and tool usage.' + : 'Yolo auto-accepts tool usage.'} +

+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'This can result in edits being applied or tools being called without user confirmation.' + : 'This can result in tools being called without user confirmation.'} +

+ {#if yoloBypassedTools.length > 0} +

Bypassed in current mode:

+
    + {#each visibleYoloBypassedTools as tool (tool.name)} +
  • {tool.label}
  • + {/each} +
+ {#if hiddenYoloBypassedToolCount > 0} +

+ {hiddenYoloBypassedToolCount} more

+ {/if} + {:else} +

No tools in the current mode require confirmation.

+ {/if} +
+ {/snippet} +
+ {/if} + {#if aiChatManager.mode === AIMode.SCRIPT && hasDiff} + + {/if} + + {/if} {#if disabled}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte index 8e57dde1fe..da1a1e26af 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte @@ -213,7 +213,7 @@ try { const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection) if (reply) { - aiChatManager.scriptEditorApplyCode?.(reply) + await aiChatManager.applyScriptEditorCode(reply) } } catch (error) { console.error('Inline AI request failed:', error) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b56edcc43..93145e3b1b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' import { get } from 'svelte/store' +import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' @@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05 const MAX_TOKENS_HARD_LIMIT = 5000 +const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' +const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' export enum AIMode { SCRIPT = 'script', @@ -77,12 +80,38 @@ export enum AIMode { ASK = 'ask' } +export enum AIAutonomyMode { + DEFAULT = 'default', + ACCEPT_EDIT = 'acceptedit', + YOLO = 'yolo' +} + const ALL_AI_MODES = Object.values(AIMode) +const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode) +const AUTO_ACCEPT_EDIT_MODES = new Set([AIMode.SCRIPT, AIMode.FLOW]) +const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.APP, + AIMode.GLOBAL +]) export function isAIMode(mode: unknown): mode is AIMode { return ALL_AI_MODES.includes(mode as AIMode) } +export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode { + return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode) +} + +export function supportsAutoAcceptEdits(mode: AIMode): boolean { + return AUTO_ACCEPT_EDIT_MODES.has(mode) +} + +export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean { + return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode) +} + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string { return path?.startsWith('f/') === true || path?.startsWith('u/') === true } +function getPersistedAutonomyMode(): AIAutonomyMode { + if (!BROWSER || typeof localStorage === 'undefined') { + return AIAutonomyMode.DEFAULT + } + const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY) + if (isAIAutonomyMode(persistedMode)) { + return persistedMode + } + return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true' + ? AIAutonomyMode.YOLO + : AIAutonomyMode.DEFAULT +} + +function persistAutonomyMode(mode: AIAutonomyMode) { + if (!BROWSER || typeof localStorage === 'undefined') { + return + } + localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode) +} + export class AIChatManager { contextManager = new ContextManager() historyManager = new HistoryManager() @@ -112,6 +161,17 @@ export class AIChatManager { currentReply = $state('') displayMessages = $state([]) messages = $state([]) + autonomyMode = $state(getPersistedAutonomyMode()) + autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) + autoAcceptEditsActive = $derived( + this.autoAcceptEditsAvailable && + (this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT || + this.autonomyMode === AIAutonomyMode.YOLO) + ) + autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode)) + autoAcceptToolConfirmationsActive = $derived( + this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable + ) #automaticScroll = $state(true) systemMessage = $state({ role: 'system', @@ -122,9 +182,9 @@ export class AIChatManager { scriptEditorOptions = $state(undefined) flowOptions = $state(undefined) - scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>( - undefined - ) + scriptEditorApplyCode = $state< + ((code: string, opts?: ReviewChangesOpts) => void | Promise) | undefined + >(undefined) scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined) scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined) flowAiChatHelpers = $state(undefined) @@ -141,7 +201,7 @@ export class AIChatManager { /** Cached datatables for app context (fetched asynchronously) */ cachedDatatables = $state([]) - private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private confirmationCallbacks = new Map void>() private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined @@ -215,20 +275,65 @@ export class AIChatManager { // Request confirmation from user for a tool call requestConfirmation = (toolId: string): Promise => { + if (this.autoAcceptToolConfirmationsActive) { + return Promise.resolve(true) + } + return new Promise((resolve) => { - // Store the callback for this specific tool - this.confirmationCallback = resolve + this.confirmationCallbacks.set(toolId, resolve) }) } // Handle confirmation response for a specific tool handleToolConfirmation = (toolId: string, confirmed: boolean) => { - if (this.confirmationCallback) { - this.confirmationCallback(confirmed) - this.confirmationCallback = undefined + const confirmationCallback = this.confirmationCallbacks.get(toolId) + if (confirmationCallback) { + confirmationCallback(confirmed) + this.confirmationCallbacks.delete(toolId) } } + private acceptPendingToolConfirmations = () => { + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(true) + } + this.confirmationCallbacks.clear() + } + + private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => { + if (flowHelpers?.hasPendingChanges()) { + flowHelpers.acceptAllModuleActions() + } + } + + setAutonomyMode = (mode: AIAutonomyMode) => { + this.autonomyMode = mode + persistAutonomyMode(mode) + + if (this.autoAcceptToolConfirmationsActive) { + this.acceptPendingToolConfirmations() + } + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } + } + + setAutoAcceptToolConfirmations = (enabled: boolean) => { + this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT) + } + + applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => { + if (this.autoAcceptEditsActive && opts?.mode === 'revert') { + return + } + + const effectiveOpts = + this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply' + ? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts) + : opts + await this.scriptEditorApplyCode?.(code, effectiveOpts) + } + requestUserQuestion = ( toolId: string, _question: { question: string; choices: string[] } @@ -346,7 +451,7 @@ export class AIChatManager { }, getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget, applyCode: (code: string, opts?: ReviewChangesOpts) => { - this.scriptEditorApplyCode?.(code, opts) + return this.applyScriptEditorCode(code, opts) }, getLintErrors: () => { if (this.scriptEditorGetLintErrors) { @@ -874,6 +979,7 @@ export class AIChatManager { } }, requestConfirmation: this.requestConfirmation, + shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive, requestUserQuestion: this.requestUserQuestion } } @@ -886,6 +992,9 @@ export class AIChatManager { ...params }) this.messages = [...this.messages, ...(addedMessages ?? [])] + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } await this.historyManager.saveChat(this.displayMessages, this.messages) } catch (err) { console.error(err) @@ -901,10 +1010,10 @@ export class AIChatManager { } cancel = (reason?: string) => { - if (this.confirmationCallback) { - this.confirmationCallback(false) - this.confirmationCallback = undefined + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(false) } + this.confirmationCallbacks.clear() for (const resolveQuestion of this.userQuestionCallbacks.values()) { resolveQuestion(undefined) } @@ -1060,10 +1169,10 @@ export class AIChatManager { listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => { if (currentEditor && currentEditor.type === 'script') { - this.scriptEditorApplyCode = (code) => { + this.scriptEditorApplyCode = async (code, opts) => { if (currentEditor && currentEditor.type === 'script') { currentEditor.hideDiffMode() - currentEditor.editor.reviewAndApplyCode(code) + await currentEditor.editor.reviewAndApplyCode(code, opts) } } this.scriptEditorShowDiffMode = () => { @@ -1164,6 +1273,11 @@ export class AIChatManager { setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => { this.flowAiChatHelpers = flowHelpers + untrack(() => { + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits(flowHelpers) + } + }) return () => { this.flowAiChatHelpers = undefined diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts new file mode 100644 index 0000000000..df2cba5575 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FlowAIChatHelpers } from './flow/core' +import type { CurrentEditor } from '$lib/components/flows/types' +import type { ReviewChangesOpts } from './monaco-adapter' +import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' + +vi.mock('monaco-editor', () => ({ + Selection: class Selection {} +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: {}, + ScriptService: {}, + FlowService: {}, + JobService: {} +})) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => undefined } +})) + +vi.mock('$lib/toast', () => ({ + sendUserToast: vi.fn() +})) + +vi.mock('$lib/aiStore', () => ({ + getCurrentModel: () => undefined, + tryGetCurrentModel: () => undefined, + getCombinedCustomPrompt: () => '' +})) + +vi.mock('../lib', () => ({ + getModelContextWindow: () => 128000, + workspaceAIClients: { subscribe: () => () => undefined } +})) + +vi.mock('./api/apiTools', () => ({ + loadApiTools: vi.fn() +})) + +vi.mock('./chatLoop', () => ({ + runChatLoop: vi.fn() +})) + +vi.mock('./global/gate', () => ({ + isGlobalAiEnabled: () => true +})) + +function createFlowHelpers({ + hasPendingChanges, + acceptAllModuleActions +}: { + hasPendingChanges: () => boolean + acceptAllModuleActions: () => void +}): FlowAIChatHelpers { + return { + getFlowAndSelectedId: vi.fn(), + getRootModules: vi.fn(), + inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() }, + setSnapshot: vi.fn(), + revertToSnapshot: vi.fn(), + setCode: vi.fn(), + setFlowJson: vi.fn(), + getFlowInputsSchema: vi.fn(), + updateExprsToSet: vi.fn(), + acceptAllModuleActions, + rejectAllModuleActions: vi.fn(), + hasPendingChanges, + selectStep: vi.fn(), + testFlow: vi.fn(), + getLintErrors: vi.fn() + } as unknown as FlowAIChatHelpers +} + +describe('AIChatManager autonomy mode', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('accepts pending flow edits when auto-accept is enabled from script mode', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('waits for flow step editor review before resolving applyScriptEditorCode', async () => { + const manager = new AIChatManager() + let finishReview: (() => void) | undefined + const reviewPromise = new Promise((resolve) => { + finishReview = resolve + }) + const hideDiffMode = vi.fn() + const reviewAndApplyCode = vi.fn(() => reviewPromise) + const opts = { mode: 'apply' } satisfies ReviewChangesOpts + + manager.listenForCurrentEditorChanges({ + type: 'script', + stepId: 'step-a', + editor: { + reviewAndApplyCode, + getLintErrors: vi.fn() + }, + showDiffMode: vi.fn(), + hideDiffMode, + diffMode: false, + lastDeployedCode: undefined + } as unknown as CurrentEditor) + + let applied = false + const applyPromise = manager + .applyScriptEditorCode('export async function main() {}', opts) + .then(() => { + applied = true + }) + + await Promise.resolve() + + expect(hideDiffMode).toHaveBeenCalledTimes(1) + expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts) + expect(applied).toBe(false) + + finishReview?.() + await applyPromise + + expect(applied).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index d8ca5858ea..9a12a04548 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index d26f5cbb54..69b868ee50 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -247,6 +247,49 @@ describe('processToolCall', () => { expect(result.content).toBe('ok') }) + it('auto-accepts required confirmations when yolo mode is active', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const fn = vi.fn().mockResolvedValue('ok') + const requestConfirmation = vi.fn() + const setToolStatus = vi.fn() + + const result = await processToolCall({ + tools: [ + { + def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'), + requiresConfirmation: true, + confirmationMessage: 'Create schedule', + fn + } + ], + toolCall: { + id: 'call_yolo', + type: 'function', + function: { name: 'create_schedule', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { + setToolStatus, + removeToolStatus: vi.fn(), + requestConfirmation, + shouldAutoAcceptToolConfirmations: () => true + } + }) + + expect(requestConfirmation).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalled() + expect(setToolStatus).toHaveBeenCalledWith( + 'call_yolo', + expect.objectContaining({ + content: 'Create schedule', + isLoading: true, + needsConfirmation: false + }) + ) + expect(result.content).toBe('ok') + }) + it('blocks workspace mutation tools for undeployed scripts and flows', async () => { const { processToolCall } = await import('./shared') const { createWorkspaceMutationTools } = await import('./workspaceTools') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 0723cc3eb3..88ed20d664 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -582,10 +582,13 @@ export async function processToolCall({ } // Check if tool requires confirmation - const needsConfirmation = tool?.requiresConfirmation + const requiresConfirmation = tool?.requiresConfirmation === true + const autoAcceptConfirmation = + requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true + const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation toolCallbacks.setToolStatus(toolCall.id, { - ...(tool?.requiresConfirmation + ...(requiresConfirmation ? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' } : {}), parameters: args, @@ -695,6 +698,7 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + shouldAutoAcceptToolConfirmations?: () => boolean requestUserQuestion?: ( toolId: string, question: UserQuestionDisplay