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
This commit is contained in:
centdix
2026-05-21 13:25:25 +00:00
committed by GitHub
parent 1ba8ed8abd
commit ac26aa4e4c
8 changed files with 573 additions and 67 deletions
@@ -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))
)
</script>
<div class="flex flex-col h-full">
@@ -322,7 +406,7 @@
<div
class={twMerge(
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-14' : 'bottom-2'
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
{#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'
)}
>
<Button
@@ -370,7 +454,7 @@
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
startIcon={{ icon: CheckIcon }}
@@ -409,49 +493,136 @@
{disabled}
isFirstMessage={messages.length === 0}
/>
<div class="flex flex-row justify-between items-center gap-x-1.5">
<div class="flex flex-row items-center gap-x-1.5">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
<div
class="flex flex-row items-center gap-x-1.5"
class:justify-between={showFooterLeftControls}
class:justify-end={!showFooterLeftControls}
>
{#if showFooterLeftControls}
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if showAutonomyModeSelector}
<div class="min-w-0">
<Popover class="max-w-full">
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
title={autonomyModeTooltip}
>
<ChevronsRight
size={13}
class={twMerge(
'shrink-0',
effectiveAutonomyMode === AIAutonomyMode.YOLO
? 'text-red-500'
: 'text-accent'
)}
/>
<span class="truncate"
>{autonomyModeLabel(
effectiveAutonomyMode,
availableAutonomyModeOptions
)}</span
>
<div class="shrink-0">
<ChevronDown size={16} />
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-32">
{#each availableAutonomyModeOptions as option (option.mode)}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
effectiveAutonomyMode === option.mode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.setAutonomyMode(option.mode)
close()
}}
>
{option.label}
</button>
{/each}
</div>
{/snippet}
</Popover>
</div>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
? 'Yolo auto-accepts edits and tool usage.'
: 'Yolo auto-accepts tool usage.'}
</p>
<p class="mt-1">
{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.'}
</p>
{#if yoloBypassedTools.length > 0}
<p class="mt-2 font-semibold">Bypassed in current mode:</p>
<ul class="mt-1 list-disc pl-4 space-y-0.5">
{#each visibleYoloBypassedTools as tool (tool.name)}
<li class="break-words">{tool.label}</li>
{/each}
</ul>
{#if hiddenYoloBypassedToolCount > 0}
<p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p>
{/if}
{:else}
<p class="mt-2">No tools in the current mode require confirmation.</p>
{/if}
</div>
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{/if}
{#if disabled}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
@@ -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)
@@ -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>([AIMode.SCRIPT, AIMode.FLOW])
const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set<AIMode>([
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<string>('')
displayMessages = $state<DisplayMessage[]>([])
messages = $state<ChatCompletionMessageParam[]>([])
autonomyMode = $state<AIAutonomyMode>(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<boolean>(true)
systemMessage = $state<ChatCompletionSystemMessageParam>({
role: 'system',
@@ -122,9 +182,9 @@ export class AIChatManager {
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
flowOptions = $state<FlowOptions | undefined>(undefined)
scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>(
undefined
)
scriptEditorApplyCode = $state<
((code: string, opts?: ReviewChangesOpts) => void | Promise<void>) | undefined
>(undefined)
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
@@ -141,7 +201,7 @@ export class AIChatManager {
/** Cached datatables for app context (fetched asynchronously) */
cachedDatatables = $state<AppDatatableElement[]>([])
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
private confirmationCallbacks = new Map<string, (value: boolean) => void>()
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
@@ -215,20 +275,65 @@ export class AIChatManager {
// Request confirmation from user for a tool call
requestConfirmation = (toolId: string): Promise<boolean> => {
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
@@ -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<void>((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)
})
})
@@ -1,6 +1,6 @@
<script lang="ts">
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, untrack } from 'svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
import type { InputTransform } from '$lib/gen'
import type { FlowAIChatHelpers } from './core'
@@ -30,6 +30,22 @@
// Get diffManager from the graph
const diffManager = $derived(flowModuleSchemaMap?.getDiffManager())
async function acceptPendingFlowEditsIfEnabled(waitForComputedDiff = false) {
if (!aiChatManager.autoAcceptEditsActive || !diffManager) {
return
}
diffManager.setCurrentFlow(flowStore.val.value)
diffManager.setCurrentInputSchema(flowStore.val.schema)
if (waitForComputedDiff) {
await tick()
}
if (diffManager.hasPendingChanges) {
diffManager.acceptAll(flowStore)
await tick()
}
}
const flowHelpers: FlowAIChatHelpers = {
// flow context
getFlowAndSelectedId: () => {
@@ -114,6 +130,7 @@
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(code)
}
await acceptPendingFlowEditsIfEnabled()
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
@@ -204,6 +221,7 @@
// Refresh the state store to update UI
refreshStateStore(flowStore)
await acceptPendingFlowEditsIfEnabled(true)
return result
} catch (error) {
throw new Error(
@@ -215,6 +233,7 @@
$effect(() => {
if (
!aiChatManager.autoAcceptEditsActive &&
$currentEditor?.type === 'script' &&
selectedId &&
diffManager?.moduleActions[selectedId]?.pending &&
@@ -99,7 +99,7 @@
function handleApplyCode() {
if (code && aiChatManager.scriptEditorApplyCode) {
aiChatManager.scriptEditorApplyCode(code, { mode: 'apply' })
void aiChatManager.applyScriptEditorCode(code, { mode: 'apply' })
}
}
</script>
@@ -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')
@@ -582,10 +582,13 @@ export async function processToolCall<T>({
}
// 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<ToolDisplayMessage>) => void
removeToolStatus: (id: string) => void
requestConfirmation?: (toolId: string) => Promise<boolean>
shouldAutoAcceptToolConfirmations?: () => boolean
requestUserQuestion?: (
toolId: string,
question: UserQuestionDisplay