diff --git a/.husky/pre-commit b/.husky/pre-commit
index 36946c38eb..f54fc9cd5c 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1 @@
-bun lint
\ No newline at end of file
+bunx lint-staged
\ No newline at end of file
diff --git a/apps/docs/content/docs/tools/gmail.mdx b/apps/docs/content/docs/tools/gmail.mdx
index 29c8239bd6..91a21eb559 100644
--- a/apps/docs/content/docs/tools/gmail.mdx
+++ b/apps/docs/content/docs/tools/gmail.mdx
@@ -79,19 +79,18 @@ Send emails using Gmail
| `threadId` | string |
| `labelIds` | string |
-### `gmail_read`
+### `gmail_draft`
-Read emails from Gmail
+Draft emails using Gmail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | Access token for Gmail API |
-| `messageId` | string | No | ID of the message to read |
-| `folder` | string | No | Folder/label to read emails from |
-| `unreadOnly` | boolean | No | Only retrieve unread messages |
-| `maxResults` | number | No | Maximum number of messages to retrieve \(default: 1, max: 10\) |
+| `to` | string | Yes | Recipient email address |
+| `subject` | string | Yes | Email subject |
+| `body` | string | Yes | Email body content |
#### Output
@@ -99,30 +98,19 @@ Read emails from Gmail
| --------- | ---- |
| `content` | string |
| `metadata` | string |
-
-### `gmail_search`
-
-Search emails in Gmail
-
-#### Input
-
-| Parameter | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `accessToken` | string | Yes | Access token for Gmail API |
-| `query` | string | Yes | Search query for emails |
-| `maxResults` | number | No | Maximum number of results to return |
-
-#### Output
-
-| Parameter | Type |
-| --------- | ---- |
-| `content` | string |
+| `message` | string |
+| `threadId` | string |
+| `labelIds` | string |
## Block Configuration
-No configuration parameters required.
+### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `operation` | string | Yes | Operation (e.g., 'send', 'draft') |
diff --git a/apps/sim/app/api/billing/webhooks/stripe/route.ts b/apps/sim/app/api/billing/webhooks/stripe/route.ts
index 8d150286d4..d6b03f32d3 100644
--- a/apps/sim/app/api/billing/webhooks/stripe/route.ts
+++ b/apps/sim/app/api/billing/webhooks/stripe/route.ts
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 400 })
}
- if (!env.STRIPE_WEBHOOK_SECRET) {
+ if (!env.STRIPE_BILLING_WEBHOOK_SECRET) {
logger.error('Missing Stripe webhook secret configuration')
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
}
@@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
// Verify webhook signature
let event: Stripe.Event
try {
- event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET)
+ event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_BILLING_WEBHOOK_SECRET)
} catch (signatureError) {
logger.error('Invalid Stripe webhook signature', {
error: signatureError,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
index 5552136b55..8b4d4674d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
@@ -430,6 +430,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={errors.minChunkSize ? 'border-red-500' : ''}
+ autoComplete='off'
+ data-form-type='other'
+ name='min-chunk-size'
/>
{errors.minChunkSize && (
{errors.minChunkSize.message}
@@ -444,6 +447,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={errors.maxChunkSize ? 'border-red-500' : ''}
+ autoComplete='off'
+ data-form-type='other'
+ name='max-chunk-size'
/>
{errors.maxChunkSize && (
{errors.maxChunkSize.message}
@@ -460,6 +466,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={errors.overlapSize ? 'border-red-500' : ''}
+ autoComplete='off'
+ data-form-type='other'
+ name='overlap-size'
/>
{errors.overlapSize && (
{errors.overlapSize.message}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx
index 373d1df896..a4fd4f4a87 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx
@@ -27,13 +27,22 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
if (isInput) {
const cleanInput = { ...data }
- // Remove sensitive fields
+ // Remove sensitive fields (common API keys and tokens)
if (cleanInput.apiKey) {
cleanInput.apiKey = '***'
}
if (cleanInput.azureApiKey) {
cleanInput.azureApiKey = '***'
}
+ if (cleanInput.token) {
+ cleanInput.token = '***'
+ }
+ if (cleanInput.accessToken) {
+ cleanInput.accessToken = '***'
+ }
+ if (cleanInput.authorization) {
+ cleanInput.authorization = '***'
+ }
// Remove null/undefined values for cleaner display
Object.keys(cleanInput).forEach((key) => {
@@ -73,6 +82,10 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
headers: response.headers,
}
+ case 'tool':
+ // For tool calls, show the result data directly
+ return response
+
default:
// For other block types, show the response content
return response
@@ -82,6 +95,70 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
return data
}
+// Collapsible Input/Output component
+interface CollapsibleInputOutputProps {
+ span: TraceSpan
+ spanId: string
+}
+
+function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
+ const [inputExpanded, setInputExpanded] = useState(false)
+ const [outputExpanded, setOutputExpanded] = useState(false)
+
+ return (
+
+ {/* Input Data - Collapsible */}
+ {span.input && (
+
+
setInputExpanded(!inputExpanded)}
+ className='flex items-center gap-2 mb-2 font-medium text-muted-foreground text-xs hover:text-foreground transition-colors'
+ >
+ {inputExpanded ? (
+
+ ) : (
+
+ )}
+ Input
+
+ {inputExpanded && (
+
+
+
+ )}
+
+ )}
+
+ {/* Output Data - Collapsible */}
+ {span.output && (
+
+
setOutputExpanded(!outputExpanded)}
+ className='flex items-center gap-2 mb-2 font-medium text-muted-foreground text-xs hover:text-foreground transition-colors'
+ >
+ {outputExpanded ? (
+
+ ) : (
+
+ )}
+ {span.status === 'error' ? 'Error Details' : 'Output'}
+
+ {outputExpanded && (
+
+
+
+ )}
+
+ )}
+
+ )
+}
+
// Component to display block input/output data in a clean, readable format
function BlockDataDisplay({
data,
@@ -531,37 +608,8 @@ function TraceSpanItem({
{/* Expanded content */}
{expanded && (
- {/* Block Input/Output Data */}
- {(span.input || span.output) && (
-
- {/* Input Data */}
- {span.input && (
-
- )}
-
- {/* Output Data */}
- {span.output && (
-
-
- {span.status === 'error' ? 'Error Details' : 'Output'}
-
-
-
-
-
- )}
-
- )}
+ {/* Block Input/Output Data - Collapsible */}
+ {(span.input || span.output) &&
}
{/* Children and tool calls */}
{/* Render child spans */}
@@ -613,9 +661,16 @@ function TraceSpanItem({
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? 'error' : 'success',
+ // Include tool call arguments as input and result as output
+ input: toolCall.input,
+ output: toolCall.error
+ ? { error: toolCall.error, ...(toolCall.output || {}) }
+ : toolCall.output,
}
- // Tool calls typically don't have sub-items
+ // Tool calls now have input/output data to display
+ const hasToolCallData = Boolean(toolCall.input || toolCall.output || toolCall.error)
+
return (
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx
index 5cf2945101..1ef0070e2b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx
@@ -192,6 +192,9 @@ export function EvalInput({
onBlur={(e) => handleRangeBlur(metric.id, 'min', e.target.value)}
disabled={isPreview || disabled}
className='placeholder:text-muted-foreground/50'
+ autoComplete='off'
+ data-form-type='other'
+ name='eval-range-min'
/>
@@ -203,6 +206,9 @@ export function EvalInput({
onBlur={(e) => handleRangeBlur(metric.id, 'max', e.target.value)}
disabled={isPreview || disabled}
className='placeholder:text-muted-foreground/50'
+ autoComplete='off'
+ data-form-type='other'
+ name='eval-range-max'
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx
index 70ee987f20..d80c8542f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx
@@ -302,12 +302,13 @@ export function LongInput({
/>
{formatDisplayText(value?.toString() ?? '', true)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx
index f71b26046c..c0f0a43c96 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx
@@ -436,6 +436,9 @@ export function ScheduleModal({
type='number'
min='1'
className='h-10'
+ autoComplete='off'
+ data-form-type='other'
+ name='minutes-interval'
/>
)}
@@ -455,6 +458,9 @@ export function ScheduleModal({
min='0'
max='59'
className='h-10'
+ autoComplete='off'
+ data-form-type='other'
+ name='hourly-minute'
/>
Specify which minute of each hour the workflow should run (0-59)
@@ -530,6 +536,9 @@ export function ScheduleModal({
min='1'
max='31'
className='h-10'
+ autoComplete='off'
+ data-form-type='other'
+ name='monthly-day'
/>
Specify which day of the month the workflow should run (1-31)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx
index 246760f320..87bbbbd776 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx
@@ -9,8 +9,10 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
-import { useSubscription } from '@/lib/auth-client'
+import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
+import { useOrganizationStore } from '@/stores/organization'
+import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('CancelSubscription')
@@ -30,7 +32,10 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
+ const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
+ const { activeOrganization } = useOrganizationStore()
+ const { getSubscriptionStatus } = useSubscriptionStore()
// Don't show for free plans
if (!subscription.isPaid) {
@@ -38,14 +43,29 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
}
const handleCancel = async () => {
+ if (!session?.user?.id) return
+
setIsLoading(true)
setError(null)
try {
- // Use Better Auth client-side cancel method
- // This redirects to Stripe Billing Portal where user can cancel
- const result = await betterAuthSubscription.cancel?.({
- returnUrl: window.location.href, // Return to current page after cancellation
+ const subscriptionStatus = getSubscriptionStatus()
+ const activeOrgId = activeOrganization?.id
+
+ let referenceId = session.user.id
+ if (subscriptionStatus.isTeam && activeOrgId) {
+ referenceId = activeOrgId
+ }
+
+ logger.info('Canceling subscription', {
+ referenceId,
+ isTeam: subscriptionStatus.isTeam,
+ activeOrgId,
+ })
+
+ const result = await betterAuthSubscription.cancel({
+ returnUrl: window.location.href,
+ referenceId,
})
if (result && 'error' in result && result.error) {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
index e48f8f22c4..527fada101 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
@@ -166,6 +166,9 @@ export function EditMemberLimitDialog({
max={10000}
step='1'
placeholder={planMinimum.toString()}
+ autoComplete='off'
+ data-form-type='other'
+ name='member-usage-limit'
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx
index e092a958da..f0ffda8af3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx
@@ -79,6 +79,9 @@ export function UsageLimitEditor({
min={minimumLimit}
step='1'
disabled={isSaving}
+ autoComplete='off'
+ data-form-type='other'
+ name='usage-limit'
/>
) : (
{currentLimit}
diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts
index d7be4e6043..2694827359 100644
--- a/apps/sim/blocks/blocks/gmail.ts
+++ b/apps/sim/blocks/blocks/gmail.ts
@@ -14,17 +14,17 @@ export const GmailBlock: BlockConfig = {
icon: GmailIcon,
subBlocks: [
// Operation selector
- // {
- // id: 'operation',
- // title: 'Operation',
- // type: 'dropdown',
- // layout: 'full',
- // options: [
- // { label: 'Send Email', id: 'send_gmail' },
- // // { label: 'Read Email', id: 'read_gmail' },
- // // { label: 'Search Emails', id: 'search_gmail' },
- // ],
- // },
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ layout: 'full',
+ options: [
+ { label: 'Send Email', id: 'send_gmail' },
+ // { label: 'Read Email', id: 'read_gmail' },
+ { label: 'Draft Email', id: 'draft_gmail' },
+ ],
+ },
// Gmail Credentials
{
id: 'credential',
@@ -48,7 +48,7 @@ export const GmailBlock: BlockConfig = {
type: 'short-input',
layout: 'full',
placeholder: 'Recipient email address',
- // condition: { field: 'operation', value: 'send_gmail' },
+ condition: { field: 'operation', value: ['send_gmail', 'draft_gmail'] },
},
{
id: 'subject',
@@ -56,7 +56,7 @@ export const GmailBlock: BlockConfig = {
type: 'short-input',
layout: 'full',
placeholder: 'Email subject',
- // condition: { field: 'operation', value: 'send_gmail' },
+ condition: { field: 'operation', value: ['send_gmail', 'draft_gmail'] },
},
{
id: 'body',
@@ -64,7 +64,7 @@ export const GmailBlock: BlockConfig = {
type: 'long-input',
layout: 'full',
placeholder: 'Email content',
- // condition: { field: 'operation', value: 'send_gmail' },
+ condition: { field: 'operation', value: ['send_gmail', 'draft_gmail'] },
},
// Read Email Fields - Add folder selector
// {
@@ -130,22 +130,17 @@ export const GmailBlock: BlockConfig = {
// },
],
tools: {
- access: ['gmail_send', 'gmail_read', 'gmail_search'],
+ access: ['gmail_send', 'gmail_draft'],
config: {
tool: (params) => {
- // Since we only have send_gmail now, we can simplify this
- return 'gmail_send'
-
- // switch (params.operation) {
- // case 'send_gmail':
- // return 'gmail_send'
- // case 'read_gmail':
- // return 'gmail_read'
- // case 'search_gmail':
- // return 'gmail_search'
- // default:
- // throw new Error(`Invalid Gmail operation: ${params.operation}`)
- // }
+ switch (params.operation) {
+ case 'send_gmail':
+ return 'gmail_send'
+ case 'draft_gmail':
+ return 'gmail_draft'
+ default:
+ throw new Error(`Invalid Gmail operation: ${params.operation}`)
+ }
},
params: (params) => {
// Pass the credential directly from the credential field
@@ -158,13 +153,13 @@ export const GmailBlock: BlockConfig = {
return {
...rest,
- credential, // Keep the credential parameter
+ credential,
}
},
},
},
inputs: {
- // operation: { type: 'string', required: true },
+ operation: { type: 'string', required: true },
credential: { type: 'string', required: true },
// Send operation inputs
to: { type: 'string', required: false },
diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts
index 7cf7257e9a..07e6b20805 100644
--- a/apps/sim/lib/env.ts
+++ b/apps/sim/lib/env.ts
@@ -17,6 +17,7 @@ export const env = createEnv({
POSTGRES_URL: z.string().url().optional(),
STRIPE_SECRET_KEY: z.string().min(1).optional(),
+ STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(),
FREE_TIER_COST_LIMIT: z.number().optional(),
diff --git a/apps/sim/lib/logs/trace-spans.test.ts b/apps/sim/lib/logs/trace-spans.test.ts
new file mode 100644
index 0000000000..4d71f8db00
--- /dev/null
+++ b/apps/sim/lib/logs/trace-spans.test.ts
@@ -0,0 +1,595 @@
+import { describe, expect, test } from 'vitest'
+import type { ExecutionResult } from '@/executor/types'
+import { buildTraceSpans, stripCustomToolPrefix } from './trace-spans'
+
+describe('buildTraceSpans', () => {
+ test('should extract sequential segments from timeSegments data', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'agent-1',
+ blockName: 'Test Agent',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:08.000Z',
+ durationMs: 8000,
+ success: true,
+ input: { userPrompt: 'Test prompt' },
+ output: {
+ content: 'Agent response',
+ model: 'gpt-4o',
+ tokens: { prompt: 10, completion: 20, total: 30 },
+ providerTiming: {
+ duration: 8000,
+ startTime: '2024-01-01T10:00:00.000Z',
+ endTime: '2024-01-01T10:00:08.000Z',
+ timeSegments: [
+ {
+ type: 'model',
+ name: 'Initial response',
+ startTime: 1704103200000, // 2024-01-01T10:00:00.000Z
+ endTime: 1704103201000, // 2024-01-01T10:00:01.000Z
+ duration: 1000,
+ },
+ {
+ type: 'tool',
+ name: 'custom_test_tool',
+ startTime: 1704103201000, // 2024-01-01T10:00:01.000Z
+ endTime: 1704103203000, // 2024-01-01T10:00:03.000Z
+ duration: 2000,
+ },
+ {
+ type: 'tool',
+ name: 'http_request',
+ startTime: 1704103203000, // 2024-01-01T10:00:03.000Z
+ endTime: 1704103206000, // 2024-01-01T10:00:06.000Z
+ duration: 3000,
+ },
+ {
+ type: 'model',
+ name: 'Model response (iteration 1)',
+ startTime: 1704103206000, // 2024-01-01T10:00:06.000Z
+ endTime: 1704103208000, // 2024-01-01T10:00:08.000Z
+ duration: 2000,
+ },
+ ],
+ },
+ toolCalls: {
+ list: [
+ {
+ name: 'custom_test_tool',
+ arguments: { input: 'test input' },
+ result: { output: 'test output' },
+ duration: 2000,
+ },
+ {
+ name: 'http_request',
+ arguments: { url: 'https://api.example.com' },
+ result: { status: 200, data: 'response' },
+ duration: 3000,
+ },
+ ],
+ count: 2,
+ },
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+ expect(agentSpan.type).toBe('agent')
+ expect(agentSpan.children).toBeDefined()
+ expect(agentSpan.children).toHaveLength(4)
+
+ // Check sequential segments
+ const segments = agentSpan.children!
+
+ // First segment: Initial model response
+ expect(segments[0].name).toBe('Initial response')
+ expect(segments[0].type).toBe('model')
+ expect(segments[0].duration).toBe(1000)
+ expect(segments[0].status).toBe('success')
+
+ // Second segment: First tool call
+ expect(segments[1].name).toBe('test_tool') // custom_ prefix should be stripped
+ expect(segments[1].type).toBe('tool')
+ expect(segments[1].duration).toBe(2000)
+ expect(segments[1].status).toBe('success')
+ expect(segments[1].input).toEqual({ input: 'test input' })
+ expect(segments[1].output).toEqual({ output: 'test output' })
+
+ // Third segment: Second tool call
+ expect(segments[2].name).toBe('http_request')
+ expect(segments[2].type).toBe('tool')
+ expect(segments[2].duration).toBe(3000)
+ expect(segments[2].status).toBe('success')
+ expect(segments[2].input).toEqual({ url: 'https://api.example.com' })
+ expect(segments[2].output).toEqual({ status: 200, data: 'response' })
+
+ // Fourth segment: Final model response
+ expect(segments[3].name).toBe('Model response (iteration 1)')
+ expect(segments[3].type).toBe('model')
+ expect(segments[3].duration).toBe(2000)
+ expect(segments[3].status).toBe('success')
+ })
+
+ test('should fallback to toolCalls extraction when timeSegments not available', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'agent-1',
+ blockName: 'Test Agent',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:05.000Z',
+ durationMs: 5000,
+ success: true,
+ input: { userPrompt: 'Test prompt' },
+ output: {
+ content: 'Agent response',
+ model: 'gpt-4o',
+ tokens: { prompt: 10, completion: 20, total: 30 },
+ providerTiming: {
+ duration: 4000,
+ startTime: '2024-01-01T10:00:00.500Z',
+ endTime: '2024-01-01T10:00:04.500Z',
+ // No timeSegments - should fallback to toolCalls
+ },
+ toolCalls: {
+ list: [
+ {
+ name: 'custom_test_tool',
+ arguments: { input: 'test input' },
+ result: { output: 'test output' },
+ duration: 1000,
+ startTime: '2024-01-01T10:00:01.000Z',
+ endTime: '2024-01-01T10:00:02.000Z',
+ },
+ {
+ name: 'http_request',
+ arguments: { url: 'https://api.example.com' },
+ result: { status: 200, data: 'response' },
+ duration: 2000,
+ startTime: '2024-01-01T10:00:02.000Z',
+ endTime: '2024-01-01T10:00:04.000Z',
+ },
+ ],
+ count: 2,
+ },
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+ expect(agentSpan.type).toBe('agent')
+ expect(agentSpan.toolCalls).toBeDefined()
+ expect(agentSpan.toolCalls).toHaveLength(2)
+
+ // Check first tool call
+ const firstToolCall = agentSpan.toolCalls![0]
+ expect(firstToolCall.name).toBe('test_tool') // custom_ prefix should be stripped
+ expect(firstToolCall.duration).toBe(1000)
+ expect(firstToolCall.status).toBe('success')
+ expect(firstToolCall.input).toEqual({ input: 'test input' })
+ expect(firstToolCall.output).toEqual({ output: 'test output' })
+
+ // Check second tool call
+ const secondToolCall = agentSpan.toolCalls![1]
+ expect(secondToolCall.name).toBe('http_request')
+ expect(secondToolCall.duration).toBe(2000)
+ expect(secondToolCall.status).toBe('success')
+ expect(secondToolCall.input).toEqual({ url: 'https://api.example.com' })
+ expect(secondToolCall.output).toEqual({ status: 200, data: 'response' })
+ })
+
+ test('should extract tool calls from agent block output with direct toolCalls array format (fallback)', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'agent-2',
+ blockName: 'Test Agent 2',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:03.000Z',
+ durationMs: 3000,
+ success: true,
+ input: { userPrompt: 'Test prompt' },
+ output: {
+ content: 'Agent response',
+ model: 'gpt-4o',
+ providerTiming: {
+ duration: 2500,
+ startTime: '2024-01-01T10:00:00.250Z',
+ endTime: '2024-01-01T10:00:02.750Z',
+ // No timeSegments - should fallback to toolCalls
+ },
+ toolCalls: [
+ {
+ name: 'serper_search',
+ arguments: { query: 'test search' },
+ result: { results: ['result1', 'result2'] },
+ duration: 1500,
+ startTime: '2024-01-01T10:00:00.500Z',
+ endTime: '2024-01-01T10:00:02.000Z',
+ },
+ ],
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+ expect(agentSpan.toolCalls).toBeDefined()
+ expect(agentSpan.toolCalls).toHaveLength(1)
+
+ const toolCall = agentSpan.toolCalls![0]
+ expect(toolCall.name).toBe('serper_search')
+ expect(toolCall.duration).toBe(1500)
+ expect(toolCall.status).toBe('success')
+ expect(toolCall.input).toEqual({ query: 'test search' })
+ expect(toolCall.output).toEqual({ results: ['result1', 'result2'] })
+ })
+
+ test('should extract tool calls from streaming response with executionData format (fallback)', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'agent-3',
+ blockName: 'Streaming Agent',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:04.000Z',
+ durationMs: 4000,
+ success: true,
+ input: { userPrompt: 'Test prompt' },
+ output: {
+ content: 'Agent response',
+ model: 'gpt-4o',
+ // No providerTiming - should fallback to executionData
+ executionData: {
+ output: {
+ toolCalls: {
+ list: [
+ {
+ name: 'custom_analysis_tool',
+ arguments: { data: 'sample data' },
+ result: { analysis: 'completed' },
+ duration: 2000,
+ startTime: '2024-01-01T10:00:01.000Z',
+ endTime: '2024-01-01T10:00:03.000Z',
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+ expect(agentSpan.toolCalls).toBeDefined()
+ expect(agentSpan.toolCalls).toHaveLength(1)
+
+ const toolCall = agentSpan.toolCalls![0]
+ expect(toolCall.name).toBe('analysis_tool') // custom_ prefix should be stripped
+ expect(toolCall.duration).toBe(2000)
+ expect(toolCall.status).toBe('success')
+ expect(toolCall.input).toEqual({ data: 'sample data' })
+ expect(toolCall.output).toEqual({ analysis: 'completed' })
+ })
+
+ test('should handle tool calls with errors in timeSegments', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'agent-4',
+ blockName: 'Error Agent',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:03.000Z',
+ durationMs: 3000,
+ success: true,
+ input: { userPrompt: 'Test prompt' },
+ output: {
+ content: 'Agent response',
+ model: 'gpt-4o',
+ providerTiming: {
+ duration: 3000,
+ startTime: '2024-01-01T10:00:00.000Z',
+ endTime: '2024-01-01T10:00:03.000Z',
+ timeSegments: [
+ {
+ type: 'model',
+ name: 'Initial response',
+ startTime: 1704103200000, // 2024-01-01T10:00:00.000Z
+ endTime: 1704103201000, // 2024-01-01T10:00:01.000Z
+ duration: 1000,
+ },
+ {
+ type: 'tool',
+ name: 'failing_tool',
+ startTime: 1704103201000, // 2024-01-01T10:00:01.000Z
+ endTime: 1704103202000, // 2024-01-01T10:00:02.000Z
+ duration: 1000,
+ },
+ {
+ type: 'model',
+ name: 'Model response (iteration 1)',
+ startTime: 1704103202000, // 2024-01-01T10:00:02.000Z
+ endTime: 1704103203000, // 2024-01-01T10:00:03.000Z
+ duration: 1000,
+ },
+ ],
+ },
+ toolCalls: {
+ list: [
+ {
+ name: 'failing_tool',
+ arguments: { input: 'test' },
+ error: 'Tool execution failed',
+ duration: 1000,
+ startTime: '2024-01-01T10:00:01.000Z',
+ endTime: '2024-01-01T10:00:02.000Z',
+ },
+ ],
+ count: 1,
+ },
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+ expect(agentSpan.children).toBeDefined()
+ expect(agentSpan.children).toHaveLength(3)
+
+ // Check the tool segment with error
+ const toolSegment = agentSpan.children![1]
+ expect(toolSegment.name).toBe('failing_tool')
+ expect(toolSegment.type).toBe('tool')
+ expect(toolSegment.status).toBe('error')
+ expect(toolSegment.input).toEqual({ input: 'test' })
+ expect(toolSegment.output).toEqual({ error: 'Tool execution failed' })
+ })
+
+ test('should handle blocks without tool calls', () => {
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final output' },
+ logs: [
+ {
+ blockId: 'text-1',
+ blockName: 'Text Block',
+ blockType: 'text',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:01.000Z',
+ durationMs: 1000,
+ success: true,
+ input: { content: 'Hello world' },
+ output: { content: 'Hello world' },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const textSpan = traceSpans[0]
+ expect(textSpan.type).toBe('text')
+ expect(textSpan.toolCalls).toBeUndefined()
+ })
+
+ test('should handle complex multi-iteration agent execution with sequential segments', () => {
+ // This test simulates a real agent execution with multiple tool calls and model iterations
+ const mockExecutionResult: ExecutionResult = {
+ success: true,
+ output: { content: 'Final comprehensive response' },
+ logs: [
+ {
+ blockId: 'agent-complex',
+ blockName: 'Multi-Tool Agent',
+ blockType: 'agent',
+ startedAt: '2024-01-01T10:00:00.000Z',
+ endedAt: '2024-01-01T10:00:15.000Z',
+ durationMs: 15000,
+ success: true,
+ input: { userPrompt: 'Research and analyze tennis news' },
+ output: {
+ content: 'Based on my research using multiple sources...',
+ model: 'gpt-4o',
+ tokens: { prompt: 50, completion: 200, total: 250 },
+ cost: { total: 0.0025, prompt: 0.001, completion: 0.0015 },
+ providerTiming: {
+ duration: 15000,
+ startTime: '2024-01-01T10:00:00.000Z',
+ endTime: '2024-01-01T10:00:15.000Z',
+ modelTime: 8000,
+ toolsTime: 6500,
+ iterations: 2,
+ firstResponseTime: 1500,
+ timeSegments: [
+ {
+ type: 'model',
+ name: 'Initial response',
+ startTime: 1704103200000, // 2024-01-01T10:00:00.000Z
+ endTime: 1704103201500, // 2024-01-01T10:00:01.500Z
+ duration: 1500,
+ },
+ {
+ type: 'tool',
+ name: 'exa_search',
+ startTime: 1704103201500, // 2024-01-01T10:00:01.500Z
+ endTime: 1704103204000, // 2024-01-01T10:00:04.000Z
+ duration: 2500,
+ },
+ {
+ type: 'tool',
+ name: 'custom_analysis_tool',
+ startTime: 1704103204000, // 2024-01-01T10:00:04.000Z
+ endTime: 1704103208000, // 2024-01-01T10:00:08.000Z
+ duration: 4000,
+ },
+ {
+ type: 'model',
+ name: 'Model response (iteration 1)',
+ startTime: 1704103208000, // 2024-01-01T10:00:08.000Z
+ endTime: 1704103211500, // 2024-01-01T10:00:11.500Z
+ duration: 3500,
+ },
+ {
+ type: 'tool',
+ name: 'http_request',
+ startTime: 1704103211500, // 2024-01-01T10:00:11.500Z
+ endTime: 1704103213500, // 2024-01-01T10:00:13.500Z
+ duration: 2000,
+ },
+ {
+ type: 'model',
+ name: 'Model response (iteration 2)',
+ startTime: 1704103213500, // 2024-01-01T10:00:13.500Z
+ endTime: 1704103215000, // 2024-01-01T10:00:15.000Z
+ duration: 1500,
+ },
+ ],
+ },
+ toolCalls: {
+ list: [
+ {
+ name: 'exa_search',
+ arguments: { query: 'tennis news 2024', apiKey: 'secret-key' },
+ result: { results: [{ title: 'Tennis News 1' }, { title: 'Tennis News 2' }] },
+ duration: 2500,
+ },
+ {
+ name: 'custom_analysis_tool',
+ arguments: { data: 'tennis data', mode: 'comprehensive' },
+ result: { analysis: 'Detailed tennis analysis', confidence: 0.95 },
+ duration: 4000,
+ },
+ {
+ name: 'http_request',
+ arguments: {
+ url: 'https://api.tennis.com/stats',
+ headers: { authorization: 'Bearer token' },
+ },
+ result: { status: 200, data: { stats: 'tennis statistics' } },
+ duration: 2000,
+ },
+ ],
+ count: 3,
+ },
+ },
+ },
+ ],
+ }
+
+ const { traceSpans } = buildTraceSpans(mockExecutionResult)
+
+ expect(traceSpans).toHaveLength(1)
+ const agentSpan = traceSpans[0]
+
+ // Verify agent span properties
+ expect(agentSpan.type).toBe('agent')
+ expect(agentSpan.name).toBe('Multi-Tool Agent')
+ expect(agentSpan.duration).toBe(15000)
+ expect(agentSpan.children).toBeDefined()
+ expect(agentSpan.children).toHaveLength(6) // 2 model + 3 tool + 1 model = 6 segments
+
+ const segments = agentSpan.children!
+
+ // Verify sequential execution flow
+ // 1. Initial model response
+ expect(segments[0].name).toBe('Initial response')
+ expect(segments[0].type).toBe('model')
+ expect(segments[0].duration).toBe(1500)
+ expect(segments[0].status).toBe('success')
+
+ // 2. First tool call - exa_search
+ expect(segments[1].name).toBe('exa_search')
+ expect(segments[1].type).toBe('tool')
+ expect(segments[1].duration).toBe(2500)
+ expect(segments[1].status).toBe('success')
+ expect(segments[1].input).toEqual({ query: 'tennis news 2024', apiKey: 'secret-key' })
+ expect(segments[1].output).toEqual({
+ results: [{ title: 'Tennis News 1' }, { title: 'Tennis News 2' }],
+ })
+
+ // 3. Second tool call - analysis_tool (custom_ prefix stripped)
+ expect(segments[2].name).toBe('analysis_tool')
+ expect(segments[2].type).toBe('tool')
+ expect(segments[2].duration).toBe(4000)
+ expect(segments[2].status).toBe('success')
+ expect(segments[2].input).toEqual({ data: 'tennis data', mode: 'comprehensive' })
+ expect(segments[2].output).toEqual({ analysis: 'Detailed tennis analysis', confidence: 0.95 })
+
+ // 4. First iteration model response
+ expect(segments[3].name).toBe('Model response (iteration 1)')
+ expect(segments[3].type).toBe('model')
+ expect(segments[3].duration).toBe(3500)
+ expect(segments[3].status).toBe('success')
+
+ // 5. Third tool call - http_request
+ expect(segments[4].name).toBe('http_request')
+ expect(segments[4].type).toBe('tool')
+ expect(segments[4].duration).toBe(2000)
+ expect(segments[4].status).toBe('success')
+ expect(segments[4].input).toEqual({
+ url: 'https://api.tennis.com/stats',
+ headers: { authorization: 'Bearer token' },
+ })
+ expect(segments[4].output).toEqual({ status: 200, data: { stats: 'tennis statistics' } })
+
+ // 6. Final iteration model response
+ expect(segments[5].name).toBe('Model response (iteration 2)')
+ expect(segments[5].type).toBe('model')
+ expect(segments[5].duration).toBe(1500)
+ expect(segments[5].status).toBe('success')
+
+ // Verify timing alignment
+ const totalSegmentTime = segments.reduce((sum, segment) => sum + segment.duration, 0)
+ expect(totalSegmentTime).toBe(15000) // Should match total agent duration
+
+ // Verify no toolCalls property exists (since we're using children instead)
+ expect(agentSpan.toolCalls).toBeUndefined()
+ })
+})
+
+describe('stripCustomToolPrefix', () => {
+ test('should strip custom_ prefix from tool names', () => {
+ expect(stripCustomToolPrefix('custom_test_tool')).toBe('test_tool')
+ expect(stripCustomToolPrefix('custom_analysis')).toBe('analysis')
+ })
+
+ test('should leave non-custom tool names unchanged', () => {
+ expect(stripCustomToolPrefix('http_request')).toBe('http_request')
+ expect(stripCustomToolPrefix('serper_search')).toBe('serper_search')
+ expect(stripCustomToolPrefix('regular_tool')).toBe('regular_tool')
+ })
+})
diff --git a/apps/sim/lib/logs/trace-spans.ts b/apps/sim/lib/logs/trace-spans.ts
index fa7e277ec0..096869146e 100644
--- a/apps/sim/lib/logs/trace-spans.ts
+++ b/apps/sim/lib/logs/trace-spans.ts
@@ -85,51 +85,100 @@ export function buildTraceSpans(result: ExecutionResult): {
endTime: providerTiming.endTime,
segments: providerTiming.timeSegments || [],
}
+ }
- // Add cost information if available
- if (log.output?.cost) {
- ;(span as any).cost = log.output.cost
- logger.debug(`Added cost to span ${span.id}`, {
+ // Always add cost, token, and model information if available (regardless of provider timing)
+ if (log.output?.cost) {
+ ;(span as any).cost = log.output.cost
+ logger.debug(`Added cost to span ${span.id}`, {
+ blockId: log.blockId,
+ blockType: log.blockType,
+ cost: log.output.cost,
+ })
+ }
+
+ if (log.output?.tokens) {
+ ;(span as any).tokens = log.output.tokens
+ logger.debug(`Added tokens to span ${span.id}`, {
+ blockId: log.blockId,
+ blockType: log.blockType,
+ tokens: log.output.tokens,
+ })
+ }
+
+ if (log.output?.model) {
+ ;(span as any).model = log.output.model
+ logger.debug(`Added model to span ${span.id}`, {
+ blockId: log.blockId,
+ blockType: log.blockType,
+ model: log.output.model,
+ })
+ }
+
+ // Enhanced approach: Use timeSegments for sequential flow if available
+ // This provides the actual model→tool→model execution sequence
+ if (
+ log.output?.providerTiming?.timeSegments &&
+ Array.isArray(log.output.providerTiming.timeSegments)
+ ) {
+ const timeSegments = log.output.providerTiming.timeSegments
+ const toolCallsData = log.output?.toolCalls?.list || log.output?.toolCalls || []
+
+ // Create child spans for each time segment
+ span.children = timeSegments.map((segment: any, index: number) => {
+ const segmentStartTime = new Date(segment.startTime).toISOString()
+ const segmentEndTime = new Date(segment.endTime).toISOString()
+
+ if (segment.type === 'tool') {
+ // Find matching tool call data for this segment
+ const matchingToolCall = toolCallsData.find(
+ (tc: any) => tc.name === segment.name || stripCustomToolPrefix(tc.name) === segment.name
+ )
+
+ return {
+ id: `${span.id}-segment-${index}`,
+ name: stripCustomToolPrefix(segment.name),
+ type: 'tool',
+ duration: segment.duration,
+ startTime: segmentStartTime,
+ endTime: segmentEndTime,
+ status: matchingToolCall?.error ? 'error' : 'success',
+ input: matchingToolCall?.arguments || matchingToolCall?.input,
+ output: matchingToolCall?.error
+ ? {
+ error: matchingToolCall.error,
+ ...(matchingToolCall.result || matchingToolCall.output || {}),
+ }
+ : matchingToolCall?.result || matchingToolCall?.output,
+ }
+ }
+ // Model segment
+ return {
+ id: `${span.id}-segment-${index}`,
+ name: segment.name,
+ type: 'model',
+ duration: segment.duration,
+ startTime: segmentStartTime,
+ endTime: segmentEndTime,
+ status: 'success',
+ }
+ })
+
+ logger.debug(
+ `Created ${span.children?.length || 0} sequential segments for span ${span.id}`,
+ {
blockId: log.blockId,
blockType: log.blockType,
- cost: log.output.cost,
- })
- }
-
- // Add token information if available
- if (log.output?.tokens) {
- ;(span as any).tokens = log.output.tokens
- logger.debug(`Added tokens to span ${span.id}`, {
- blockId: log.blockId,
- blockType: log.blockType,
- tokens: log.output.tokens,
- })
- }
-
- // Add model information
- if (log.output?.model) {
- ;(span as any).model = log.output.model
- logger.debug(`Added model to span ${span.id}`, {
- blockId: log.blockId,
- blockType: log.blockType,
- model: log.output.model,
- })
- }
+ segments:
+ span.children?.map((child) => ({
+ name: child.name,
+ type: child.type,
+ duration: child.duration,
+ })) || [],
+ }
+ )
} else {
- // When not using provider timing, still add cost and token information
- if (log.output?.cost) {
- ;(span as any).cost = log.output.cost
- }
-
- if (log.output?.tokens) {
- ;(span as any).tokens = log.output.tokens
- }
-
- if (log.output?.model) {
- ;(span as any).model = log.output.model
- }
-
- // When not using provider timing at all, add tool calls if they exist
+ // Fallback: Extract tool calls using the original approach for backwards compatibility
// Tool calls handling for different formats:
// 1. Standard format in response.toolCalls.list
// 2. Direct toolCalls array in response
@@ -154,11 +203,14 @@ export function buildTraceSpans(result: ExecutionResult): {
// Validate that toolCallsList is actually an array before processing
if (toolCallsList && !Array.isArray(toolCallsList)) {
- console.warn(`toolCallsList is not an array: ${typeof toolCallsList}`)
+ logger.warn(`toolCallsList is not an array: ${typeof toolCallsList}`, {
+ blockId: log.blockId,
+ blockType: log.blockType,
+ })
toolCallsList = []
}
} catch (error) {
- console.error(`Error extracting toolCalls: ${error}`)
+ logger.error(`Error extracting toolCalls from block ${log.blockId}:`, error)
toolCallsList = [] // Set to empty array as fallback
}
@@ -180,11 +232,17 @@ export function buildTraceSpans(result: ExecutionResult): {
error: tc.error,
}
} catch (tcError) {
- console.error(`Error processing tool call: ${tcError}`)
+ logger.error(`Error processing tool call in block ${log.blockId}:`, tcError)
return null
}
})
.filter(Boolean) // Remove any null entries from failed processing
+
+ logger.debug(`Added ${span.toolCalls?.length || 0} tool calls to span ${span.id}`, {
+ blockId: log.blockId,
+ blockType: log.blockType,
+ toolCallNames: span.toolCalls?.map((tc) => tc.name) || [],
+ })
}
}
diff --git a/apps/sim/tools/gmail/draft.ts b/apps/sim/tools/gmail/draft.ts
new file mode 100644
index 0000000000..36d38899c1
--- /dev/null
+++ b/apps/sim/tools/gmail/draft.ts
@@ -0,0 +1,101 @@
+import type { ToolConfig } from '../types'
+import type { GmailSendParams, GmailToolResponse } from './types'
+
+const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
+
+export const gmailDraftTool: ToolConfig = {
+ id: 'gmail_draft',
+ name: 'Gmail Draft',
+ description: 'Draft emails using Gmail',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'google-email',
+ additionalScopes: ['https://www.googleapis.com/auth/gmail.compose'],
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ description: 'Access token for Gmail API',
+ },
+ to: {
+ type: 'string',
+ required: true,
+ description: 'Recipient email address',
+ },
+ subject: {
+ type: 'string',
+ required: true,
+ description: 'Email subject',
+ },
+ body: {
+ type: 'string',
+ required: true,
+ description: 'Email body content',
+ },
+ },
+
+ request: {
+ url: () => `${GMAIL_API_BASE}/drafts`,
+ method: 'POST',
+ headers: (params: GmailSendParams) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params: GmailSendParams): Record => {
+ const email = [
+ 'Content-Type: text/plain; charset="UTF-8"',
+ 'MIME-Version: 1.0',
+ `To: ${params.to}`,
+ `Subject: ${params.subject}`,
+ '',
+ params.body,
+ ].join('\n')
+
+ return {
+ message: {
+ raw: Buffer.from(email).toString('base64url'),
+ },
+ }
+ },
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || 'Failed to draft email')
+ }
+
+ return {
+ success: true,
+ output: {
+ content: 'Email drafted successfully',
+ metadata: {
+ id: data.id,
+ message: {
+ id: data.message?.id,
+ threadId: data.message?.threadId,
+ labelIds: data.message?.labelIds,
+ },
+ },
+ },
+ }
+ },
+
+ transformError: (error) => {
+ if (error.error?.message) {
+ if (error.error.message.includes('invalid authentication credentials')) {
+ return 'Invalid or expired access token. Please reauthenticate.'
+ }
+ if (error.error.message.includes('quota')) {
+ return 'Gmail API quota exceeded. Please try again later.'
+ }
+ return error.error.message
+ }
+ return error.message || 'An unexpected error occurred while drafting email'
+ },
+}
diff --git a/apps/sim/tools/gmail/index.ts b/apps/sim/tools/gmail/index.ts
index 5f2c35861d..3558d482c2 100644
--- a/apps/sim/tools/gmail/index.ts
+++ b/apps/sim/tools/gmail/index.ts
@@ -1,5 +1,6 @@
+import { gmailDraftTool } from './draft'
import { gmailReadTool } from './read'
import { gmailSearchTool } from './search'
import { gmailSendTool } from './send'
-export { gmailSendTool, gmailReadTool, gmailSearchTool }
+export { gmailSendTool, gmailReadTool, gmailSearchTool, gmailDraftTool }
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index fbaeb2dbb9..258c707df0 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -25,7 +25,7 @@ import {
githubPrTool,
githubRepoInfoTool,
} from './github'
-import { gmailReadTool, gmailSearchTool, gmailSendTool } from './gmail'
+import { gmailDraftTool, gmailReadTool, gmailSearchTool, gmailSendTool } from './gmail'
import { searchTool as googleSearchTool } from './google'
import {
googleCalendarCreateTool,
@@ -142,6 +142,7 @@ export const tools: Record = {
gmail_send: gmailSendTool,
gmail_read: gmailReadTool,
gmail_search: gmailSearchTool,
+ gmail_draft: gmailDraftTool,
whatsapp_send_message: whatsappSendMessageTool,
x_write: xWriteTool,
x_read: xReadTool,
diff --git a/package.json b/package.json
index 849306d755..7489bb0898 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss,md}": [
- "biome check --files-ignore-unknown=true"
+ "biome check --write --files-ignore-unknown=true"
],
"!.github/*.md": []
}