Compare commits

..

1 Commits

Author SHA1 Message Date
Emir Karabeg
a89c16bf98 feat(emails): broadcast v0.5 added 2026-01-20 11:47:59 -08:00
37 changed files with 647 additions and 1641 deletions

View File

@@ -33,7 +33,6 @@ const BlockDataSchema = z.object({
doWhileCondition: z.string().optional(),
parallelType: z.enum(['collection', 'count']).optional(),
type: z.string().optional(),
canonicalModes: z.record(z.enum(['basic', 'advanced'])).optional(),
})
const SubBlockStateSchema = z.object({

View File

@@ -538,11 +538,15 @@ export function Document({
},
{
onSuccess: (result) => {
if (operation === 'delete' || result.errorCount > 0) {
if (operation === 'delete') {
refreshChunks()
} else {
chunks.forEach((chunk) => {
updateChunk(chunk.id, { enabled: operation === 'enable' })
result.results.forEach((opResult) => {
if (opResult.operation === operation) {
opResult.chunkIds.forEach((chunkId: string) => {
updateChunk(chunkId, { enabled: operation === 'enable' })
})
}
})
}
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)

View File

@@ -462,7 +462,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
definition.
@@ -470,7 +470,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{selectedTagUsage?.documentCount === 0 ? (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This tag definition is not being used by any documents. You can safely delete it
to free up the tag slot.
</p>

View File

@@ -2,7 +2,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { Badge, Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import {
@@ -257,9 +257,9 @@ export const LogDetails = memo(function LogDetails({
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
<Badge variant='green' size='md' className='max-w-full truncate'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</Badge>
</div>
</div>
)}

View File

@@ -283,7 +283,7 @@ export function GeneralDeploy({
<ModalContent size='sm'>
<ModalHeader>Promote to live</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to promote{' '}
<span className='font-medium text-[var(--text-primary)]'>
{versionToPromoteInfo?.name || `v${versionToPromote}`}

View File

@@ -591,11 +591,12 @@ export function DeployModal({
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<div>
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
Edit API Info
</Button>
</div>
<div className='flex items-center gap-2'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}

View File

@@ -42,7 +42,7 @@ export function CodeEditor({
placeholder = '',
className = '',
gutterClassName = '',
minHeight,
minHeight = '360px',
highlightVariables = true,
onKeyDown,
disabled = false,
@@ -186,7 +186,7 @@ export function CodeEditor({
}
return (
<Code.Container className={className} style={minHeight ? { minHeight } : undefined}>
<Code.Container className={className} style={{ minHeight }}>
{showWandButton && onWandClick && (
<Button
variant='ghost'
@@ -220,7 +220,7 @@ export function CodeEditor({
disabled={disabled}
{...getCodeEditorProps({ disabled })}
className={cn(getCodeEditorProps({ disabled }).className, 'h-full')}
style={minHeight ? { minHeight } : undefined}
style={{ minHeight }}
textareaClassName={cn(
getCodeEditorProps({ disabled }).textareaClassName,
'!block !h-full !min-h-full'

View File

@@ -87,16 +87,15 @@ export function CustomToolModal({
const [codeError, setCodeError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [toolId, setToolId] = useState<string | undefined>(undefined)
const [initialJsonSchema, setInitialJsonSchema] = useState('')
const [initialFunctionCode, setInitialFunctionCode] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showDiscardAlert, setShowDiscardAlert] = useState(false)
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
const [schemaPromptInput, setSchemaPromptInput] = useState('')
const [schemaPromptSummary, setSchemaPromptSummary] = useState<string | null>(null)
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
const [codePromptInput, setCodePromptInput] = useState('')
const [codePromptSummary, setCodePromptSummary] = useState<string | null>(null)
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
const schemaGeneration = useWand({
@@ -175,9 +174,6 @@ Example 2:
generationType: 'custom-tool-schema',
},
currentValue: jsonSchema,
onStreamStart: () => {
setJsonSchema('')
},
onGeneratedContent: (content) => {
setJsonSchema(content)
setSchemaError(null)
@@ -241,9 +237,6 @@ try {
generationType: 'javascript-function-body',
},
currentValue: functionCode,
onStreamStart: () => {
setFunctionCode('')
},
onGeneratedContent: (content) => {
handleFunctionCodeChange(content)
setCodeError(null)
@@ -279,15 +272,12 @@ try {
if (initialValues) {
try {
const schemaValue =
setJsonSchema(
typeof initialValues.schema === 'string'
? initialValues.schema
: JSON.stringify(initialValues.schema, null, 2)
const codeValue = initialValues.code || ''
setJsonSchema(schemaValue)
setFunctionCode(codeValue)
setInitialJsonSchema(schemaValue)
setInitialFunctionCode(codeValue)
)
setFunctionCode(initialValues.code || '')
setIsEditing(true)
setToolId(initialValues.id)
} catch (error) {
@@ -314,18 +304,17 @@ try {
const resetForm = () => {
setJsonSchema('')
setFunctionCode('')
setInitialJsonSchema('')
setInitialFunctionCode('')
setSchemaError(null)
setCodeError(null)
setActiveSection('schema')
setIsEditing(false)
setToolId(undefined)
setSchemaPromptSummary(null)
setCodePromptSummary(null)
setIsSchemaPromptActive(false)
setIsCodePromptActive(false)
setSchemaPromptInput('')
setCodePromptInput('')
setShowDiscardAlert(false)
schemaGeneration.closePrompt()
schemaGeneration.hidePromptInline()
codeGeneration.closePrompt()
@@ -339,37 +328,31 @@ try {
onOpenChange(false)
}
const validateSchema = (schema: string): { isValid: boolean; error: string | null } => {
if (!schema) return { isValid: false, error: null }
const validateJsonSchema = (schema: string): boolean => {
if (!schema) return false
try {
const parsed = JSON.parse(schema)
if (!parsed.type || parsed.type !== 'function') {
return { isValid: false, error: 'Missing "type": "function"' }
}
if (!parsed.function || !parsed.function.name) {
return { isValid: false, error: 'Missing function.name field' }
}
if (!parsed.function.parameters) {
return { isValid: false, error: 'Missing function.parameters object' }
}
if (!parsed.function.parameters.type) {
return { isValid: false, error: 'Missing parameters.type field' }
}
if (parsed.function.parameters.properties === undefined) {
return { isValid: false, error: 'Missing parameters.properties field' }
}
if (
typeof parsed.function.parameters.properties !== 'object' ||
parsed.function.parameters.properties === null
) {
return { isValid: false, error: 'parameters.properties must be an object' }
return false
}
return { isValid: true, error: null }
} catch {
return { isValid: false, error: 'Invalid JSON format' }
if (!parsed.function || !parsed.function.name) {
return false
}
if (!parsed.function.parameters) {
return false
}
if (!parsed.function.parameters.type || parsed.function.parameters.properties === undefined) {
return false
}
return true
} catch (_error) {
return false
}
}
@@ -391,32 +374,7 @@ try {
}
}, [jsonSchema])
const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema])
const hasChanges = useMemo(() => {
if (!isEditing) return true
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
const hasUnsavedChanges = useMemo(() => {
if (isEditing) {
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
}
return jsonSchema.trim().length > 0 || functionCode.trim().length > 0
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
const handleCloseAttempt = () => {
if (hasUnsavedChanges && !schemaGeneration.isStreaming && !codeGeneration.isStreaming) {
setShowDiscardAlert(true)
} else {
handleClose()
}
}
const handleConfirmDiscard = () => {
setShowDiscardAlert(false)
handleClose()
}
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
const handleSave = async () => {
try {
@@ -426,9 +384,43 @@ try {
return
}
const { isValid, error } = validateSchema(jsonSchema)
if (!isValid) {
setSchemaError(error)
const parsed = JSON.parse(jsonSchema)
if (!parsed.type || parsed.type !== 'function') {
setSchemaError('Schema must have a "type" field set to "function"')
setActiveSection('schema')
return
}
if (!parsed.function || !parsed.function.name) {
setSchemaError('Schema must have a "function" object with a "name" field')
setActiveSection('schema')
return
}
if (!parsed.function.parameters) {
setSchemaError('Missing function.parameters object')
setActiveSection('schema')
return
}
if (!parsed.function.parameters.type) {
setSchemaError('Missing parameters.type field')
setActiveSection('schema')
return
}
if (parsed.function.parameters.properties === undefined) {
setSchemaError('Missing parameters.properties field')
setActiveSection('schema')
return
}
if (
typeof parsed.function.parameters.properties !== 'object' ||
parsed.function.parameters.properties === null
) {
setSchemaError('parameters.properties must be an object')
setActiveSection('schema')
return
}
@@ -491,9 +483,17 @@ try {
}
onSave(customTool)
setSchemaPromptSummary(null)
setCodePromptSummary(null)
handleClose()
} catch (error) {
logger.error('Error saving custom tool:', { error })
setSchemaPromptSummary(null)
setCodePromptSummary(null)
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
if (errorMessage.includes('Cannot change function name')) {
@@ -512,8 +512,46 @@ try {
setJsonSchema(value)
if (value.trim()) {
const { error } = validateSchema(value)
setSchemaError(error)
try {
const parsed = JSON.parse(value)
if (!parsed.type || parsed.type !== 'function') {
setSchemaError('Missing "type": "function"')
return
}
if (!parsed.function || !parsed.function.name) {
setSchemaError('Missing function.name field')
return
}
if (!parsed.function.parameters) {
setSchemaError('Missing function.parameters object')
return
}
if (!parsed.function.parameters.type) {
setSchemaError('Missing parameters.type field')
return
}
if (parsed.function.parameters.properties === undefined) {
setSchemaError('Missing parameters.properties field')
return
}
if (
typeof parsed.function.parameters.properties !== 'object' ||
parsed.function.parameters.properties === null
) {
setSchemaError('parameters.properties must be an object')
return
}
setSchemaError(null)
} catch {
setSchemaError('Invalid JSON format')
}
} else {
setSchemaError(null)
}
@@ -671,12 +709,12 @@ try {
e.preventDefault()
e.stopPropagation()
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
return
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
return
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
@@ -684,17 +722,14 @@ try {
const selectedParam = schemaParameters[schemaParamSelectedIndex]
handleSchemaParamSelect(selectedParam.name)
}
return
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setShowSchemaParams(false)
return
case ' ':
case 'Tab':
setShowSchemaParams(false)
return
break
}
return
}
if (showEnvVars || showTags) {
@@ -708,7 +743,7 @@ try {
const handleSchemaWandClick = () => {
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
setIsSchemaPromptActive(true)
setSchemaPromptInput('')
setSchemaPromptInput(schemaPromptSummary ?? '')
setTimeout(() => {
schemaPromptInputRef.current?.focus()
}, 0)
@@ -727,6 +762,7 @@ try {
const handleSchemaPromptSubmit = () => {
const trimmedPrompt = schemaPromptInput.trim()
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
setSchemaPromptSummary(trimmedPrompt)
schemaGeneration.generateStream({ prompt: trimmedPrompt })
setSchemaPromptInput('')
setIsSchemaPromptActive(false)
@@ -746,7 +782,7 @@ try {
const handleCodeWandClick = () => {
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
setIsCodePromptActive(true)
setCodePromptInput('')
setCodePromptInput(codePromptSummary ?? '')
setTimeout(() => {
codePromptInputRef.current?.focus()
}, 0)
@@ -765,6 +801,7 @@ try {
const handleCodePromptSubmit = () => {
const trimmedPrompt = codePromptInput.trim()
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
setCodePromptSummary(trimmedPrompt)
codeGeneration.generateStream({ prompt: trimmedPrompt })
setCodePromptInput('')
setIsCodePromptActive(false)
@@ -809,8 +846,19 @@ try {
return (
<>
<Modal open={open} onOpenChange={handleCloseAttempt}>
<ModalContent size='xl'>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent
size='xl'
onKeyDown={(e) => {
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()
e.stopPropagation()
setShowEnvVars(false)
setShowTags(false)
setShowSchemaParams(false)
}
}}
>
<ModalHeader>{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}</ModalHeader>
<ModalTabs
@@ -1163,7 +1211,7 @@ try {
<Button
variant='tertiary'
onClick={handleSave}
disabled={!isSchemaValid || !!schemaError || !hasChanges}
disabled={!isSchemaValid || !!schemaError}
>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
@@ -1200,26 +1248,6 @@ try {
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showDiscardAlert} onOpenChange={setShowDiscardAlert}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
You have unsaved changes to this tool. Are you sure you want to discard your changes
and close the editor?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDiscardAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleConfirmDiscard}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1072,7 +1072,7 @@ export function AccessControl() {
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
You have unsaved changes. Do you want to save them before closing?
</p>
</ModalBody>

View File

@@ -115,7 +115,7 @@ export function CreateApiKeyModal({
<ModalContent size='sm'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
@@ -218,7 +218,7 @@ export function CreateApiKeyModal({
<ModalContent size='sm'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.

View File

@@ -222,7 +222,7 @@ export function BYOK() {
)}
</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '}
requests in this workspace. Your key is encrypted and stored securely.
</p>
@@ -308,7 +308,7 @@ export function BYOK() {
<ModalContent size='sm'>
<ModalHeader>Delete API Key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete the{' '}
<span className='font-medium text-[var(--text-primary)]'>
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}

View File

@@ -214,7 +214,7 @@ export function Copilot() {
<ModalContent size='sm'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This key will allow access to Copilot features. Make sure to copy it after creation as
you won't be able to see it again.
</p>
@@ -276,7 +276,7 @@ export function Copilot() {
<ModalContent size='sm'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.

View File

@@ -824,7 +824,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{hasConflicts || hasInvalidKeys
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
: 'You have unsaved changes. Do you want to save them before closing?'}

View File

@@ -603,7 +603,7 @@ export function General({ onOpenChange }: GeneralProps) {
<ModalContent size='sm'>
<ModalHeader>Reset Password</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
A password reset link will be sent to{' '}
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
Click the link in the email to create a new password.

View File

@@ -64,7 +64,7 @@ export function TeamSeats({
<ModalContent size='sm'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>{description}</p>
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
<div className='mt-[16px] flex flex-col gap-[4px]'>
<Label htmlFor='seats' className='text-[12px]'>

View File

@@ -25,11 +25,9 @@ const GRID_COLUMNS = 6
function ColorGrid({
hexInput,
setHexInput,
onColorChange,
}: {
hexInput: string
setHexInput: (color: string) => void
onColorChange?: (color: string) => void
}) {
const { isInFolder } = usePopoverContext()
const [focusedIndex, setFocusedIndex] = useState(-1)
@@ -74,9 +72,7 @@ function ColorGrid({
case 'Enter':
case ' ':
e.preventDefault()
e.stopPropagation()
setHexInput(WORKFLOW_COLORS[index].color)
onColorChange?.(WORKFLOW_COLORS[index].color)
return
default:
return
@@ -87,7 +83,7 @@ function ColorGrid({
buttonRefs.current[newIndex]?.focus()
}
},
[setHexInput, onColorChange]
[setHexInput]
)
return (
@@ -109,10 +105,8 @@ function ColorGrid({
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
className={cn(
'h-[20px] w-[20px] rounded-[4px] outline-none ring-white ring-offset-0',
(focusedIndex === index ||
(focusedIndex === -1 && hexInput.toLowerCase() === color.toLowerCase())) &&
'ring-[1.5px]'
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
@@ -456,11 +450,7 @@ export function ContextMenu({
>
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
{/* Preset colors with keyboard navigation */}
<ColorGrid
hexInput={hexInput}
setHexInput={setHexInput}
onColorChange={onColorChange}
/>
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
{/* Hex input */}
<div className='flex items-center gap-[4px]'>

View File

@@ -459,7 +459,6 @@ export function WorkspaceHeader({
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
setIsListRenaming(true)

View File

@@ -57,12 +57,6 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
type: 'switch',
placeholder: 'Save browser data',
},
{
id: 'profile_id',
title: 'Profile ID',
type: 'short-input',
placeholder: 'Enter browser profile ID (optional)',
},
{
id: 'apiKey',
title: 'API Key',
@@ -81,7 +75,6 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
variables: { type: 'json', description: 'Task variables' },
model: { type: 'string', description: 'AI model to use' },
save_browser_data: { type: 'boolean', description: 'Save browser data' },
profile_id: { type: 'string', description: 'Browser profile ID for persistent sessions' },
},
outputs: {
id: { type: 'string', description: 'Task execution identifier' },

View File

@@ -460,13 +460,6 @@ const PopoverContent = React.forwardRef<
const content = contentRef.current
if (!content) return
const activeElement = document.activeElement
const isInputFocused =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.getAttribute('contenteditable') === 'true'
if (isInputFocused) return
const items = content.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([aria-disabled="true"])'
)

View File

@@ -0,0 +1,292 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What's New at Sim</title>
</head>
<body style="margin:0;padding:0;background-color:#ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" style="background-color:#ffffff;">
<tr>
<td align="center" style="padding:0 16px;">
<table width="600" cellspacing="0" cellpadding="0" border="0" role="presentation" style="max-width:600px;width:100%;">
<!-- Logo -->
<tr>
<td align="center" style="padding-top:32px;padding-bottom:16px;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/logo.svg" width="79" alt="Sim Logo" style="display:block;width:79px;" border="0">
</a>
</td>
</tr>
<!-- Intro Text -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:25px;text-align:left;">
<p style="margin:0;">We've been heads down building features to make Sim the best way to build, deploy, and observe AI workflows. Here's what's new: <strong>Copilot</strong> for context-aware assistance, <strong>MCP deployment</strong> to expose workflows as tools, a complete <strong>Logs & Dashboard</strong> for execution observability, and <strong>realtime collaboration</strong> so your team can build together.</p>
</td>
</tr>
<!-- CTA Button -->
<tr>
<td align="center" style="padding-bottom:15px;padding-top:15px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:6px 14px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Try Sim</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr>
<td align="center" style="padding-bottom:10px;padding-top:10px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Section Header: What's New -->
<tr>
<td align="left" style="color:#5c5c5c;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;word-break:break-word;line-height:1;padding-bottom:11px;padding-top:9px;text-align:left;">
<p style="margin:0;"><em>Build smarter, ship faster</em></p>
</td>
</tr>
<tr>
<td align="left" valign="top" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;word-break:break-word;line-height:1;padding-bottom:15px;text-align:left;">
<h2 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:32px;word-break:break-word;">What's new in Sim</h2>
</td>
</tr>
<!-- FEATURE 1: Copilot -->
<tr>
<td align="center" style="padding-top:15px;border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/copilot.gif" width="570" alt="Sim Copilot" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:25px;text-align:left;">
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Copilot</strong></h4>
<br>
<p style="margin:0;font-size:16px;">Copilot is a context-aware assistant embedded directly into the Sim editor. It has first-class access to your workflows, blocks, execution logs, and documentation—helping you build, debug, and optimize without leaving the canvas. Ask it to explain a workflow, propose changes, or execute actions. All writes are gated behind explicit approval, so you stay in control.</p>
</td>
</tr>
<!-- FEATURE 2: MCP Deployment -->
<tr>
<td align="center" style="padding-top:15px;">
<a href="https://sim.ai" style="text-decoration:none;display:block;" target="_blank">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" style="background-color:#181C1E;border-radius:8px;">
<tr>
<td align="center" style="padding:40px 30px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="padding-bottom:16px;">
<img src="https://sim.ai/email/broadcast/v0.5/mcp.png" width="48" height="48" alt="MCP" style="display:block;" border="0">
</td>
</tr>
<tr>
<td align="center">
<p style="margin:0;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:24px;font-weight:bold;">MCP Deployment</p>
</td>
</tr>
<tr>
<td align="center" style="padding-top:8px;">
<p style="margin:0;color:#a0a0a0;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:14px;">Expose workflows as tools for any agent</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:25px;text-align:left;">
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>MCP Deployment</strong></h4>
<br>
<p style="margin:0;font-size:16px;">Deploy any workflow as an MCP tool and expose it to any MCP-compatible agent—Claude Desktop, Cursor, or your own applications. Define custom tool names, descriptions, and parameter schemas. Your workflows become reusable building blocks that other agents can invoke, turning Sim into the backend for your agentic systems.</p>
</td>
</tr>
<!-- FEATURE 3: Logs & Dashboard -->
<tr>
<td align="center" style="padding-top:15px;border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/dashboard.jpeg" width="570" alt="Logs & Dashboard" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:25px;text-align:left;">
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Logs & Dashboard</strong></h4>
<br>
<p style="margin:0;font-size:16px;">Full observability for every execution. Trace spans show you exactly what happened at each step—durations, token usage, cost breakdowns by model, and error details when things go wrong. Filter by time range, trigger type, or workflow. Live mode auto-refreshes every 5 seconds. Restore any previous workflow state with execution snapshots.</p>
</td>
</tr>
<!-- FEATURE 4: Collaboration -->
<tr>
<td align="center" style="padding-top:15px;border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/studio/multiplayer/cover.png" width="570" alt="Realtime Collaboration" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:25px;text-align:left;">
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Realtime Collaboration</strong></h4>
<br>
<p style="margin:0;font-size:16px;">Invite teammates to your workspace and build workflows together in realtime—like Figma for AI agents. See cursors move, blocks added, and configurations updated as they happen. The operation queue ensures reliability even under poor network conditions. Undo/redo works per-user, so your changes stay separate from your collaborators'.</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td align="center" style="padding-bottom:10px;padding-top:20px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Final CTA -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:15px;padding-top:15px;text-align:left;">
<p style="margin:0;">Ready to build? These features are available now in Sim.</p>
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:30px;padding-top:15px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:6px 14px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Get Started</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Footer Section -->
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" style="background-color:#F7F9FC;">
<tr>
<td align="center">
<table width="600" cellspacing="0" cellpadding="0" border="0" role="presentation" style="max-width:600px;width:100%;">
<tbody>
<!-- Top spacer -->
<tr>
<td height="32" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Social links row -->
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tbody>
<tr>
<td align="center" style="padding:0 8px 0 0;">
<a href="https://sim.ai/x" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/x-icon.png" width="20" height="20" alt="X" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/discord" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/discord-icon.png" width="20" height="20" alt="Discord" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/github" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/github-icon.png" width="20" height="20" alt="GitHub" style="display:block;" border="0">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Address row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Sim, 80 Langton St, San Francisco, CA 94103, USA
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Contact row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Questions? <a href="mailto:support@sim.ai" style="color:#737373;text-decoration:underline;font-weight:normal;">support@sim.ai</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Links row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
<a href="https://sim.ai/privacy" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Privacy Policy</a>
&nbsp;&nbsp;
<a href="https://sim.ai/terms" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Terms of Service</a>
&nbsp;&nbsp;
<a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Unsubscribe</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Copyright row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
© 2026 Sim, All Rights Reserved
</td>
</tr>
<!-- Bottom spacer -->
<tr>
<td height="32" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,599 +0,0 @@
/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/execution/cancellation', () => ({
isExecutionCancelled: vi.fn(),
isRedisCancellationEnabled: vi.fn(),
}))
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { ExecutionEngine } from './engine'
function createMockBlock(id: string): SerializedBlock {
return {
id,
metadata: { id: 'test', name: 'Test Block' },
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createMockNode(id: string, blockType = 'test'): DAGNode {
return {
id,
block: {
...createMockBlock(id),
metadata: { id: blockType, name: `Block ${id}` },
},
outgoingEdges: new Map(),
incomingEdges: new Set(),
metadata: {},
}
}
function createMockContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'test-workflow',
workspaceId: 'test-workspace',
executionId: 'test-execution',
userId: 'test-user',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
loopExecutions: new Map(),
parallelExecutions: new Map(),
completedLoops: new Set(),
activeExecutionPath: new Set(),
metadata: {
executionId: 'test-execution',
startTime: new Date().toISOString(),
pendingBlocks: [],
},
envVars: {},
...overrides,
}
}
function createMockDAG(nodes: DAGNode[]): DAG {
const nodeMap = new Map<string, DAGNode>()
nodes.forEach((node) => nodeMap.set(node.id, node))
return {
nodes: nodeMap,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
interface MockEdgeManager extends EdgeManager {
processOutgoingEdges: ReturnType<typeof vi.fn>
}
function createMockEdgeManager(
processOutgoingEdgesImpl?: (node: DAGNode) => string[]
): MockEdgeManager {
const mockFn = vi.fn().mockImplementation(processOutgoingEdgesImpl || (() => []))
return {
processOutgoingEdges: mockFn,
isNodeReady: vi.fn().mockReturnValue(true),
deactivateEdgeAndDescendants: vi.fn(),
restoreIncomingEdge: vi.fn(),
clearDeactivatedEdges: vi.fn(),
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as MockEdgeManager
}
interface MockNodeOrchestrator extends NodeExecutionOrchestrator {
executionCount: number
}
function createMockNodeOrchestrator(executeDelay = 0): MockNodeOrchestrator {
const mock = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async () => {
mock.executionCount++
if (executeDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, executeDelay))
}
return { nodeId: 'test', output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
}
return mock as unknown as MockNodeOrchestrator
}
describe('ExecutionEngine', () => {
beforeEach(() => {
vi.clearAllMocks()
;(isExecutionCancelled as Mock).mockResolvedValue(false)
;(isRedisCancellationEnabled as Mock).mockReturnValue(false)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Normal execution', () => {
it('should execute a simple linear workflow', async () => {
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
startNode.outgoingEdges.set('edge1', { target: 'end' })
endNode.incomingEdges.add('start')
const dag = createMockDAG([startNode, endNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(2)
})
it('should mark execution as successful when completed without cancellation', async () => {
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(result.status).toBeUndefined()
})
it('should execute all nodes in a multi-node workflow', async () => {
const nodes = [
createMockNode('start', 'starter'),
createMockNode('middle1', 'function'),
createMockNode('middle2', 'function'),
createMockNode('end', 'function'),
]
nodes[0].outgoingEdges.set('e1', { target: 'middle1' })
nodes[1].outgoingEdges.set('e2', { target: 'middle2' })
nodes[2].outgoingEdges.set('e3', { target: 'end' })
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['middle1']
if (node.id === 'middle1') return ['middle2']
if (node.id === 'middle2') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(4)
})
})
describe('Cancellation via AbortSignal', () => {
it('should stop execution immediately when aborted before start', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should stop execution when aborted mid-workflow', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
let callCount = 0
const edgeManager = createMockEdgeManager((node) => {
callCount++
if (callCount === 2) abortController.abort()
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(5)
})
it('should not wait for slow executions when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNode = createMockNode('slow', 'function')
startNode.outgoingEdges.set('edge1', { target: 'slow' })
const dag = createMockDAG([startNode, slowNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['slow']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(500)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(400)
})
it('should return cancelled status even if error thrown during cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.success).toBe(false)
})
})
describe('Cancellation via Redis', () => {
it('should check Redis for cancellation when enabled', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect(isExecutionCancelled as Mock).toHaveBeenCalled()
})
it('should stop execution when Redis reports cancellation', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
let checkCount = 0
;(isExecutionCancelled as Mock).mockImplementation(async () => {
checkCount++
return checkCount > 1
})
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(150)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
})
it('should respect cancellation check interval', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
})
})
describe('Loop execution with cancellation', () => {
it('should break out of loop when cancelled mid-iteration', async () => {
const abortController = new AbortController()
const loopStartNode = createMockNode('loop-start', 'loop_sentinel')
loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: 'loop1' }
const loopBodyNode = createMockNode('loop-body', 'function')
loopBodyNode.metadata = { isLoopNode: true, loopId: 'loop1' }
const loopEndNode = createMockNode('loop-end', 'loop_sentinel')
loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: 'loop1' }
loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' })
loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' })
loopEndNode.outgoingEdges.set('loop_continue', {
target: 'loop-start',
sourceHandle: 'loop_continue',
})
const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode])
const context = createMockContext({ abortSignal: abortController.signal })
let iterationCount = 0
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'loop-start') return ['loop-body']
if (node.id === 'loop-body') return ['loop-end']
if (node.id === 'loop-end') {
iterationCount++
if (iterationCount === 3) abortController.abort()
return ['loop-start']
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(5)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('loop-start')
expect(result.status).toBe('cancelled')
expect(iterationCount).toBeLessThan(100)
})
})
describe('Parallel execution with cancellation', () => {
it('should stop queueing parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const parallelNodes = Array.from({ length: 10 }, (_, i) =>
createMockNode(`parallel${i}`, 'function')
)
parallelNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
})
const dag = createMockDAG([startNode, ...parallelNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') {
return parallelNodes.map((_, i) => `parallel${i}`)
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(50)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 30)
const result = await executionPromise
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(11)
})
it('should not wait for all parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNodes = Array.from({ length: 5 }, (_, i) => createMockNode(`slow${i}`, 'function'))
slowNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `slow${i}` })
})
const dag = createMockDAG([startNode, ...slowNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return slowNodes.map((_, i) => `slow${i}`)
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(200)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(500)
})
})
describe('Edge cases', () => {
it('should handle empty DAG gracefully', async () => {
const dag = createMockDAG([])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run()
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should preserve partial output when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
endNode.outgoingEdges = new Map()
startNode.outgoingEdges.set('edge1', { target: 'end' })
const dag = createMockDAG([startNode, endNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'start') {
return { nodeId: 'start', output: { startData: 'value' }, isFinalOutput: false }
}
abortController.abort()
return { nodeId: 'end', output: { endData: 'value' }, isFinalOutput: true }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.output).toBeDefined()
})
it('should populate metadata on cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.metadata).toBeDefined()
expect(result.metadata.endTime).toBeDefined()
expect(result.metadata.duration).toBeDefined()
})
it('should return logs even when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
context.blockLogs.push({
blockId: 'test',
blockName: 'Test',
blockType: 'test',
startedAt: '',
endedAt: '',
durationMs: 0,
success: true,
})
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
abortController.abort()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.logs).toBeDefined()
expect(result.logs.length).toBeGreaterThan(0)
})
})
describe('Cancellation flag behavior', () => {
it('should set cancelledFlag when abort signal fires', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 3 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'node0') {
abortController.abort()
return ['node1']
}
return node.id === 'node1' ? ['node2'] : []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.status).toBe('cancelled')
})
it('should cache Redis cancellation result', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(true)
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('node0')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeLessThanOrEqual(3)
})
})
})

View File

@@ -28,8 +28,6 @@ export class ExecutionEngine {
private lastCancellationCheck = 0
private readonly useRedisCancellation: boolean
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
private abortPromise: Promise<void> | null = null
private abortResolve: (() => void) | null = null
constructor(
private context: ExecutionContext,
@@ -39,34 +37,6 @@ export class ExecutionEngine {
) {
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId
this.initializeAbortHandler()
}
/**
* Sets up a single abort promise that can be reused throughout execution.
* This avoids creating multiple event listeners and potential memory leaks.
*/
private initializeAbortHandler(): void {
if (!this.context.abortSignal) return
if (this.context.abortSignal.aborted) {
this.cancelledFlag = true
this.abortPromise = Promise.resolve()
return
}
this.abortPromise = new Promise<void>((resolve) => {
this.abortResolve = resolve
})
this.context.abortSignal.addEventListener(
'abort',
() => {
this.cancelledFlag = true
this.abortResolve?.()
},
{ once: true }
)
}
private async checkCancellation(): Promise<boolean> {
@@ -103,15 +73,12 @@ export class ExecutionEngine {
this.initializeQueue(triggerBlockId)
while (this.hasWork()) {
if (await this.checkCancellation()) {
if ((await this.checkCancellation()) && this.executing.size === 0) {
break
}
await this.processQueue()
}
if (!this.cancelledFlag) {
await this.waitForAllExecutions()
}
await this.waitForAllExecutions()
if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime)
@@ -197,7 +164,11 @@ export class ExecutionEngine {
private trackExecution(promise: Promise<void>): void {
this.executing.add(promise)
promise.catch(() => {})
// Attach error handler to prevent unhandled rejection warnings
// The actual error handling happens in waitForAllExecutions/waitForAnyExecution
promise.catch(() => {
// Error will be properly handled by Promise.all/Promise.race in wait methods
})
promise.finally(() => {
this.executing.delete(promise)
})
@@ -205,30 +176,12 @@ export class ExecutionEngine {
private async waitForAnyExecution(): Promise<void> {
if (this.executing.size > 0) {
const abortPromise = this.getAbortPromise()
if (abortPromise) {
await Promise.race([...this.executing, abortPromise])
} else {
await Promise.race(this.executing)
}
await Promise.race(this.executing)
}
}
private async waitForAllExecutions(): Promise<void> {
const abortPromise = this.getAbortPromise()
if (abortPromise) {
await Promise.race([Promise.all(this.executing), abortPromise])
} else {
await Promise.all(this.executing)
}
}
/**
* Returns the cached abort promise. This is safe to call multiple times
* as it reuses the same promise instance created during initialization.
*/
private getAbortPromise(): Promise<void> | null {
return this.abortPromise
await Promise.all(Array.from(this.executing))
}
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
@@ -324,7 +277,7 @@ export class ExecutionEngine {
this.trackExecution(promise)
}
if (this.executing.size > 0 && !this.cancelledFlag) {
if (this.executing.size > 0) {
await this.waitForAnyExecution()
}
}
@@ -383,6 +336,7 @@ export class ExecutionEngine {
this.addMultipleToQueue(readyNodes)
// Check for dynamically added nodes (e.g., from parallel expansion)
if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) {
const dynamicNodes = this.context.pendingDynamicNodes
this.context.pendingDynamicNodes = []

View File

@@ -377,7 +377,10 @@ function buildManualTriggerOutput(
return mergeFilesIntoOutput(output, workflowInput)
}
function buildIntegrationTriggerOutput(workflowInput: unknown): NormalizedBlockOutput {
function buildIntegrationTriggerOutput(
_finalInput: unknown,
workflowInput: unknown
): NormalizedBlockOutput {
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
}
@@ -427,7 +430,7 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali
return buildManualTriggerOutput(finalInput, workflowInput)
case StartBlockPath.EXTERNAL_TRIGGER:
return buildIntegrationTriggerOutput(workflowInput)
return buildIntegrationTriggerOutput(finalInput, workflowInput)
case StartBlockPath.LEGACY_STARTER:
return buildLegacyStarterOutput(

View File

@@ -755,11 +755,12 @@ export interface BulkChunkOperationParams {
}
export interface BulkChunkOperationResult {
operation: string
successCount: number
errorCount: number
processed: number
errors: string[]
failedCount: number
results: Array<{
operation: string
chunkIds: string[]
}>
}
export async function bulkChunkOperation({

View File

@@ -897,17 +897,6 @@ export function useCollaborativeWorkflow() {
// Collect all edge IDs to remove
const edgeIdsToRemove = updates.flatMap((u) => u.affectedEdges.map((e) => e.id))
if (edgeIdsToRemove.length > 0) {
const edgeOperationId = crypto.randomUUID()
addToQueue({
id: edgeOperationId,
operation: {
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { ids: edgeIdsToRemove },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchRemoveEdges(edgeIdsToRemove)
}

View File

@@ -54,17 +54,6 @@ export interface SimplifiedImapEmail {
}
export interface ImapWebhookPayload {
messageId: string
subject: string
from: string
to: string
cc: string
date: string | null
bodyText: string
bodyHtml: string
mailbox: string
hasAttachments: boolean
attachments: ImapAttachment[]
email: SimplifiedImapEmail
timestamp: string
}
@@ -624,17 +613,6 @@ async function processEmails(
}
const payload: ImapWebhookPayload = {
messageId: simplifiedEmail.messageId,
subject: simplifiedEmail.subject,
from: simplifiedEmail.from,
to: simplifiedEmail.to,
cc: simplifiedEmail.cc,
date: simplifiedEmail.date,
bodyText: simplifiedEmail.bodyText,
bodyHtml: simplifiedEmail.bodyHtml,
mailbox: simplifiedEmail.mailbox,
hasAttachments: simplifiedEmail.hasAttachments,
attachments: simplifiedEmail.attachments,
email: simplifiedEmail,
timestamp: new Date().toISOString(),
}

View File

@@ -48,9 +48,6 @@ interface RssFeed {
}
export interface RssWebhookPayload {
title?: string
link?: string
pubDate?: string
item: RssItem
feed: {
title?: string
@@ -352,9 +349,6 @@ async function processRssItems(
`${webhookData.id}:${itemGuid}`,
async () => {
const payload: RssWebhookPayload = {
title: item.title,
link: item.link,
pubDate: item.pubDate,
item: {
title: item.title,
link: item.link,

View File

@@ -686,9 +686,6 @@ export async function formatWebhookInput(
if (foundWebhook.provider === 'rss') {
if (body && typeof body === 'object' && 'item' in body) {
return {
title: body.title,
link: body.link,
pubDate: body.pubDate,
item: body.item,
feed: body.feed,
timestamp: body.timestamp,
@@ -700,17 +697,6 @@ export async function formatWebhookInput(
if (foundWebhook.provider === 'imap') {
if (body && typeof body === 'object' && 'email' in body) {
return {
messageId: body.messageId,
subject: body.subject,
from: body.from,
to: body.to,
cc: body.cc,
date: body.date,
bodyText: body.bodyText,
bodyHtml: body.bodyHtml,
mailbox: body.mailbox,
hasAttachments: body.hasAttachments,
attachments: body.attachments,
email: body.email,
timestamp: body.timestamp,
}

View File

@@ -19,7 +19,6 @@ import {
convertToGeminiFormat,
convertUsageMetadata,
createReadableStreamFromGeminiStream,
ensureStructResponse,
extractFunctionCallPart,
extractTextContent,
mapToThinkingLevel,
@@ -105,7 +104,7 @@ async function executeToolCall(
const duration = toolCallEndTime - toolCallStartTime
const resultContent: Record<string, unknown> = result.success
? ensureStructResponse(result.output)
? (result.output as Record<string, unknown>)
: { error: true, message: result.error || 'Tool execution failed', tool: toolName }
const toolCall: FunctionCallResponse = {

View File

@@ -1,453 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { convertToGeminiFormat, ensureStructResponse } from '@/providers/google/utils'
import type { ProviderRequest } from '@/providers/types'
describe('ensureStructResponse', () => {
describe('should return objects unchanged', () => {
it('should return plain object unchanged', () => {
const input = { key: 'value', nested: { a: 1 } }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference
expect(result).toEqual({ key: 'value', nested: { a: 1 } })
})
it('should return empty object unchanged', () => {
const input = {}
const result = ensureStructResponse(input)
expect(result).toBe(input)
expect(result).toEqual({})
})
})
describe('should wrap primitive values in { value: ... }', () => {
it('should wrap boolean true', () => {
const result = ensureStructResponse(true)
expect(result).toEqual({ value: true })
expect(typeof result).toBe('object')
})
it('should wrap boolean false', () => {
const result = ensureStructResponse(false)
expect(result).toEqual({ value: false })
expect(typeof result).toBe('object')
})
it('should wrap string', () => {
const result = ensureStructResponse('success')
expect(result).toEqual({ value: 'success' })
expect(typeof result).toBe('object')
})
it('should wrap empty string', () => {
const result = ensureStructResponse('')
expect(result).toEqual({ value: '' })
expect(typeof result).toBe('object')
})
it('should wrap number', () => {
const result = ensureStructResponse(42)
expect(result).toEqual({ value: 42 })
expect(typeof result).toBe('object')
})
it('should wrap zero', () => {
const result = ensureStructResponse(0)
expect(result).toEqual({ value: 0 })
expect(typeof result).toBe('object')
})
it('should wrap null', () => {
const result = ensureStructResponse(null)
expect(result).toEqual({ value: null })
expect(typeof result).toBe('object')
})
it('should wrap undefined', () => {
const result = ensureStructResponse(undefined)
expect(result).toEqual({ value: undefined })
expect(typeof result).toBe('object')
})
})
describe('should wrap arrays in { value: ... }', () => {
it('should wrap array of strings', () => {
const result = ensureStructResponse(['a', 'b', 'c'])
expect(result).toEqual({ value: ['a', 'b', 'c'] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
it('should wrap array of objects', () => {
const result = ensureStructResponse([{ id: 1 }, { id: 2 }])
expect(result).toEqual({ value: [{ id: 1 }, { id: 2 }] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
it('should wrap empty array', () => {
const result = ensureStructResponse([])
expect(result).toEqual({ value: [] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
})
describe('edge cases', () => {
it('should handle nested objects correctly', () => {
const input = { a: { b: { c: 1 } }, d: [1, 2, 3] }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference, unchanged
})
it('should handle object with array property correctly', () => {
const input = { items: ['a', 'b'], count: 2 }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference, unchanged
})
})
})
describe('convertToGeminiFormat', () => {
describe('tool message handling', () => {
it('should convert tool message with object response correctly', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city": "London"}' },
},
],
},
{
role: 'tool',
name: 'get_weather',
tool_call_id: 'call_123',
content: '{"temperature": 20, "condition": "sunny"}',
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
expect(toolResponseContent).toBeDefined()
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(functionResponse?.response).toEqual({ temperature: 20, condition: 'sunny' })
expect(typeof functionResponse?.response).toBe('object')
})
it('should wrap boolean true response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Check if user exists' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'user_exists', arguments: '{"userId": "123"}' },
},
],
},
{
role: 'tool',
name: 'user_exists',
tool_call_id: 'call_456',
content: 'true', // Boolean true as JSON string
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
expect(toolResponseContent).toBeDefined()
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).not.toBe(true)
expect(functionResponse?.response).toEqual({ value: true })
})
it('should wrap boolean false response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Check if user exists' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_789',
type: 'function',
function: { name: 'user_exists', arguments: '{"userId": "999"}' },
},
],
},
{
role: 'tool',
name: 'user_exists',
tool_call_id: 'call_789',
content: 'false', // Boolean false as JSON string
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: false })
})
it('should wrap string response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get status' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_str',
type: 'function',
function: { name: 'get_status', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_status',
tool_call_id: 'call_str',
content: '"success"', // String as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: 'success' })
})
it('should wrap number response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get count' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_num',
type: 'function',
function: { name: 'get_count', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_count',
tool_call_id: 'call_num',
content: '42', // Number as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: 42 })
})
it('should wrap null response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get data' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_null',
type: 'function',
function: { name: 'get_data', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_data',
tool_call_id: 'call_null',
content: 'null', // null as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: null })
})
it('should keep array response as-is since arrays are valid Struct values', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get items' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_arr',
type: 'function',
function: { name: 'get_items', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_items',
tool_call_id: 'call_arr',
content: '["item1", "item2"]', // Array as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: ['item1', 'item2'] })
})
it('should handle invalid JSON by wrapping in output object', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get data' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_invalid',
type: 'function',
function: { name: 'get_data', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_data',
tool_call_id: 'call_invalid',
content: 'not valid json {',
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ output: 'not valid json {' })
})
it('should handle empty content by wrapping in output object', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Do something' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_empty',
type: 'function',
function: { name: 'do_action', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'do_action',
tool_call_id: 'call_empty',
content: '', // Empty content - falls back to default '{}'
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
// Empty string is not valid JSON, so it falls back to { output: "" }
expect(functionResponse?.response).toEqual({ output: '' })
})
})
})

View File

@@ -18,22 +18,6 @@ import { trackForcedToolUsage } from '@/providers/utils'
const logger = createLogger('GoogleUtils')
/**
* Ensures a value is a valid object for Gemini's functionResponse.response field.
* Gemini's API requires functionResponse.response to be a google.protobuf.Struct,
* which must be an object with string keys. Primitive values (boolean, string,
* number, null) and arrays are wrapped in { value: ... }.
*
* @param value - The value to ensure is a Struct-compatible object
* @returns A Record<string, unknown> suitable for functionResponse.response
*/
export function ensureStructResponse(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return { value }
}
/**
* Usage metadata for Google Gemini responses
*/
@@ -196,8 +180,7 @@ export function convertToGeminiFormat(request: ProviderRequest): {
}
let responseData: Record<string, unknown>
try {
const parsed = JSON.parse(message.content ?? '{}')
responseData = ensureStructResponse(parsed)
responseData = JSON.parse(message.content ?? '{}')
} catch {
responseData = { output: message.content }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@@ -0,0 +1,24 @@
<svg width="79" height="79" viewBox="0 0 79 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_3011_11314)">
<path d="M63 2.5H16C9.37258 2.5 4 7.87258 4 14.5V61.5C4 68.1274 9.37258 73.5 16 73.5H63C69.6274 73.5 75 68.1274 75 61.5V14.5C75 7.87258 69.6274 2.5 63 2.5Z" fill="#FEFEFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.0264 35.5016C39.0264 36.0181 38.822 36.514 38.4593 36.8792L38.4073 36.9316C38.0449 37.298 37.5523 37.5028 37.0394 37.5028H25.4362C24.3675 37.5028 23.5 38.3754 23.5 39.4515V51.9998C23.5 53.0761 24.3675 53.9487 25.4362 53.9487H37.8946C38.9632 53.9487 39.8296 53.0761 39.8296 51.9998V40.2513C39.8296 39.7723 40.0182 39.3128 40.3548 38.9739C40.6903 38.6359 41.1466 38.4458 41.6223 38.4458H53.4199C54.4885 38.4458 55.3548 37.5733 55.3548 36.4971V23.9488C55.3548 22.8726 54.4885 22 53.4199 22H40.9614C39.8928 22 39.0264 22.8726 39.0264 23.9488V35.5016ZM42.8072 24.6712H51.5731C52.1976 24.6712 52.7026 25.1809 52.7026 25.8088V34.6371C52.7026 35.265 52.1976 35.7747 51.5731 35.7747H42.8072C42.1837 35.7747 41.6776 35.265 41.6776 34.6371V25.8088C41.6776 25.1809 42.1837 24.6712 42.8072 24.6712Z" fill="#33C482"/>
<path d="M53.4366 40.6562H44.1739C43.051 40.6562 42.1406 41.5731 42.1406 42.7041V51.9191C42.1406 53.05 43.051 53.9669 44.1739 53.9669H53.4366C54.5596 53.9669 55.47 53.05 55.47 51.9191V42.7041C55.47 41.5731 54.5596 40.6562 53.4366 40.6562Z" fill="#33C482"/>
<path d="M53.4366 40.6133H44.1739C43.051 40.6133 42.1406 41.53 42.1406 42.6611V51.876C42.1406 53.0069 43.051 53.9236 44.1739 53.9236H53.4366C54.5596 53.9236 55.47 53.0069 55.47 51.876V42.6611C55.47 41.53 54.5596 40.6133 53.4366 40.6133Z" fill="url(#paint0_linear_3011_11314)" fill-opacity="0.2"/>
</g>
<defs>
<filter id="filter0_d_3011_11314" x="0" y="0" width="79" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.5"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3011_11314"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3011_11314" result="shape"/>
</filter>
<linearGradient id="paint0_linear_3011_11314" x1="42.1406" y1="40.6133" x2="50.2327" y2="48.6865" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -337,11 +337,10 @@ async function handleBlockOperationTx(
const currentData = currentBlock?.data || {}
// Update data with parentId and extent
const { parentId: _removedParentId, extent: _removedExtent, ...restData } = currentData
const updatedData = isRemovingFromParent
? restData
? {} // Clear data entirely when removing from parent
: {
...restData,
...currentData,
...(payload.parentId ? { parentId: payload.parentId } : {}),
...(payload.extent ? { extent: payload.extent } : {}),
}
@@ -829,11 +828,10 @@ async function handleBlocksOperationTx(
const currentData = currentBlock?.data || {}
const { parentId: _removedParentId, extent: _removedExtent, ...restData } = currentData
const updatedData = isRemovingFromParent
? restData
? {}
: {
...restData,
...currentData,
...(parentId ? { parentId, extent: 'parent' } : {}),
}

View File

@@ -1,214 +1,11 @@
import { createLogger } from '@sim/logger'
import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types'
import type { ToolConfig, ToolResponse } from '@/tools/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('BrowserUseTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = 180000
const MAX_CONSECUTIVE_ERRORS = 3
async function createSessionWithProfile(
profileId: string,
apiKey: string
): Promise<{ sessionId: string } | { error: string }> {
try {
const response = await fetch('https://api.browser-use.com/api/v2/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Browser-Use-API-Key': apiKey,
},
body: JSON.stringify({
profileId: profileId.trim(),
}),
})
if (!response.ok) {
const errorText = await response.text()
logger.error(`Failed to create session with profile: ${errorText}`)
return { error: `Failed to create session with profile: ${response.statusText}` }
}
const data = (await response.json()) as { id: string }
logger.info(`Created session ${data.id} with profile ${profileId}`)
return { sessionId: data.id }
} catch (error: any) {
logger.error('Error creating session with profile:', error)
return { error: `Error creating session: ${error.message}` }
}
}
async function stopSession(sessionId: string, apiKey: string): Promise<void> {
try {
const response = await fetch(`https://api.browser-use.com/api/v2/sessions/${sessionId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Browser-Use-API-Key': apiKey,
},
body: JSON.stringify({ action: 'stop' }),
})
if (response.ok) {
logger.info(`Stopped session ${sessionId}`)
} else {
logger.warn(`Failed to stop session ${sessionId}: ${response.statusText}`)
}
} catch (error: any) {
logger.warn(`Error stopping session ${sessionId}:`, error)
}
}
function buildRequestBody(
params: BrowserUseRunTaskParams,
sessionId?: string
): Record<string, any> {
const requestBody: Record<string, any> = {
task: params.task,
}
if (sessionId) {
requestBody.sessionId = sessionId
logger.info(`Using session ${sessionId} for task`)
}
if (params.variables) {
let secrets: Record<string, string> = {}
if (Array.isArray(params.variables)) {
logger.info('Converting variables array to dictionary format')
params.variables.forEach((row: any) => {
if (row.cells?.Key && row.cells.Value !== undefined) {
secrets[row.cells.Key] = row.cells.Value
logger.info(`Added secret for key: ${row.cells.Key}`)
} else if (row.Key && row.Value !== undefined) {
secrets[row.Key] = row.Value
logger.info(`Added secret for key: ${row.Key}`)
}
})
} else if (typeof params.variables === 'object' && params.variables !== null) {
logger.info('Using variables object directly')
secrets = params.variables
}
if (Object.keys(secrets).length > 0) {
logger.info(`Found ${Object.keys(secrets).length} secrets to include`)
requestBody.secrets = secrets
} else {
logger.warn('No usable secrets found in variables')
}
}
if (params.model) {
requestBody.llm_model = params.model
}
if (params.save_browser_data) {
requestBody.save_browser_data = params.save_browser_data
}
requestBody.use_adblock = true
requestBody.highlight_elements = true
return requestBody
}
async function fetchTaskStatus(
taskId: string,
apiKey: string
): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
try {
const response = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
method: 'GET',
headers: {
'X-Browser-Use-API-Key': apiKey,
},
})
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` }
}
const data = await response.json()
return { ok: true, data }
} catch (error: any) {
return { ok: false, error: error.message || 'Network error' }
}
}
async function pollForCompletion(
taskId: string,
apiKey: string
): Promise<{ success: boolean; output: any; steps: any[]; error?: string }> {
let liveUrlLogged = false
let consecutiveErrors = 0
const startTime = Date.now()
while (Date.now() - startTime < MAX_POLL_TIME_MS) {
const result = await fetchTaskStatus(taskId, apiKey)
if (!result.ok) {
consecutiveErrors++
logger.warn(
`Error polling task ${taskId} (attempt ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${result.error}`
)
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
logger.error(`Max consecutive errors reached for task ${taskId}`)
return {
success: false,
output: null,
steps: [],
error: `Failed to poll task status after ${MAX_CONSECUTIVE_ERRORS} attempts: ${result.error}`,
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
continue
}
consecutiveErrors = 0
const taskData = result.data
const status = taskData.status
logger.info(`BrowserUse task ${taskId} status: ${status}`)
if (['finished', 'failed', 'stopped'].includes(status)) {
return {
success: status === 'finished',
output: taskData.output ?? null,
steps: taskData.steps || [],
}
}
if (!liveUrlLogged && taskData.live_url) {
logger.info(`BrowserUse task ${taskId} live URL: ${taskData.live_url}`)
liveUrlLogged = true
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}
const finalResult = await fetchTaskStatus(taskId, apiKey)
if (finalResult.ok && ['finished', 'failed', 'stopped'].includes(finalResult.data.status)) {
return {
success: finalResult.data.status === 'finished',
output: finalResult.data.output ?? null,
steps: finalResult.data.steps || [],
}
}
logger.warn(
`Task ${taskId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`
)
return {
success: false,
output: null,
steps: [],
error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`,
}
}
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 180000 // 3 minutes maximum polling time
export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskResponse> = {
id: 'browser_use_run_task',
@@ -247,14 +44,7 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
visibility: 'user-only',
description: 'API key for BrowserUse API',
},
profile_id: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Browser profile ID for persistent sessions (cookies, login state)',
},
},
request: {
url: 'https://api.browser-use.com/api/v2/tasks',
method: 'POST',
@@ -262,94 +52,155 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
'Content-Type': 'application/json',
'X-Browser-Use-API-Key': params.apiKey,
}),
body: (params) => {
const requestBody: Record<string, any> = {
task: params.task,
}
if (params.variables) {
let secrets: Record<string, string> = {}
if (Array.isArray(params.variables)) {
logger.info('Converting variables array to dictionary format')
params.variables.forEach((row) => {
if (row.cells?.Key && row.cells.Value !== undefined) {
secrets[row.cells.Key] = row.cells.Value
logger.info(`Added secret for key: ${row.cells.Key}`)
} else if (row.Key && row.Value !== undefined) {
secrets[row.Key] = row.Value
logger.info(`Added secret for key: ${row.Key}`)
}
})
} else if (typeof params.variables === 'object' && params.variables !== null) {
logger.info('Using variables object directly')
secrets = params.variables
}
if (Object.keys(secrets).length > 0) {
logger.info(`Found ${Object.keys(secrets).length} secrets to include`)
requestBody.secrets = secrets
} else {
logger.warn('No usable secrets found in variables')
}
}
if (params.model) {
requestBody.llm_model = params.model
}
if (params.save_browser_data) {
requestBody.save_browser_data = params.save_browser_data
}
requestBody.use_adblock = true
requestBody.highlight_elements = true
return requestBody
},
},
directExecution: async (params: BrowserUseRunTaskParams): Promise<ToolResponse> => {
let sessionId: string | undefined
transformResponse: async (response: Response) => {
const data = (await response.json()) as { id: string }
return {
success: true,
output: {
id: data.id,
success: true,
output: null,
steps: [],
},
}
},
if (params.profile_id) {
logger.info(`Creating session with profile ID: ${params.profile_id}`)
const sessionResult = await createSessionWithProfile(params.profile_id, params.apiKey)
if ('error' in sessionResult) {
return {
success: false,
output: {
id: null,
success: false,
output: null,
steps: [],
},
error: sessionResult.error,
}
}
sessionId = sessionResult.sessionId
postProcess: async (result, params) => {
if (!result.success) {
return result
}
const requestBody = buildRequestBody(params, sessionId)
logger.info('Creating BrowserUse task', { hasSession: !!sessionId })
const taskId = result.output.id
let liveUrlLogged = false
try {
const response = await fetch('https://api.browser-use.com/api/v2/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Browser-Use-API-Key': params.apiKey,
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error(`Failed to create task: ${errorText}`)
return {
success: false,
output: {
id: null,
success: false,
output: null,
steps: [],
const initialTaskResponse = await fetch(
`https://api.browser-use.com/api/v2/tasks/${taskId}`,
{
method: 'GET',
headers: {
'X-Browser-Use-API-Key': params.apiKey,
},
error: `Failed to create task: ${response.statusText}`,
}
)
if (initialTaskResponse.ok) {
const initialTaskData = await initialTaskResponse.json()
if (initialTaskData.live_url) {
logger.info(
`BrowserUse task ${taskId} launched with live URL: ${initialTaskData.live_url}`
)
liveUrlLogged = true
}
}
} catch (error) {
logger.warn(`Failed to get initial task details for ${taskId}:`, error)
}
const data = (await response.json()) as { id: string }
const taskId = data.id
logger.info(`Created BrowserUse task: ${taskId}`)
let elapsedTime = 0
const result = await pollForCompletion(taskId, params.apiKey)
while (elapsedTime < MAX_POLL_TIME_MS) {
try {
const statusResponse = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
method: 'GET',
headers: {
'X-Browser-Use-API-Key': params.apiKey,
},
})
if (sessionId) {
await stopSession(sessionId, params.apiKey)
if (!statusResponse.ok) {
throw new Error(`Failed to get task status: ${statusResponse.statusText}`)
}
const taskData = await statusResponse.json()
const status = taskData.status
logger.info(`BrowserUse task ${taskId} status: ${status}`)
if (['finished', 'failed', 'stopped'].includes(status)) {
result.output = {
id: taskId,
success: status === 'finished',
output: taskData.output ?? null,
steps: taskData.steps || [],
}
return result
}
if (!liveUrlLogged && status === 'running' && taskData.live_url) {
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
liveUrlLogged = true
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
elapsedTime += POLL_INTERVAL_MS
} catch (error: any) {
logger.error('Error polling for task status:', {
message: error.message || 'Unknown error',
taskId,
})
return {
...result,
error: `Error polling for task status: ${error.message || 'Unknown error'}`,
}
}
}
return {
success: result.success && !result.error,
output: {
id: taskId,
success: result.success,
output: result.output,
steps: result.steps,
},
error: result.error,
}
} catch (error: any) {
logger.error('Error creating BrowserUse task:', error)
if (sessionId) {
await stopSession(sessionId, params.apiKey)
}
return {
success: false,
output: {
id: null,
success: false,
output: null,
steps: [],
},
error: `Error creating task: ${error.message}`,
}
logger.warn(
`Task ${taskId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`
)
return {
...result,
error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`,
}
},

View File

@@ -6,7 +6,6 @@ export interface BrowserUseRunTaskParams {
variables?: Record<string, string>
model?: string
save_browser_data?: boolean
profile_id?: string
}
export interface BrowserUseTaskStep {