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 && ( +
+ + {inputExpanded && ( +
+ +
+ )} +
+ )} + + {/* Output Data - Collapsible */} + {span.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 && ( -
-

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": [] }