mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 14:45:16 -05:00
fix(tool-input): added tool input, visibility enum for tool params, fixed google provider bugs (#674)
* transfrom from block-centric tool input component to tool-centric tool input component for agent tools * added additional type safety, created generic wrapper for tool input & reused across all subblock types * stop retries if tool call fails, implemented for all providers except google * bug fix with tool name extraction * another bug fix * ran script to update docs * update contributing guide tool/block add example to reflect new param structure * update README * add key control to combobox, fixed google * fixed google provider, fixed combobox * fixed a ton of tools, ensured that the agent tool has full parity with actual tool for all tools * update docs to reflect new structure * updated visibility for gmail draft * standardize dropdown values for tool definitions * add asterisk for user-only + required fields * updated visibility for tools * consolidate redactApiKey util, fixed console entry bug that overwrites previous block logs * updated docs * update contributing guide to guide users to point their branches at staging instead of main * nits * move socket tests
This commit is contained in:
@@ -19,21 +19,25 @@ export const gmailDraftTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Access token for Gmail API',
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Recipient email address',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email subject',
|
||||
},
|
||||
body: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email body content',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Gmail Read Tool Unit Tests
|
||||
*
|
||||
* This file contains unit tests for the Gmail Read tool, which is used
|
||||
* to fetch emails from Gmail via the Gmail API.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { mockGmailResponses } from '../__test-utils__/mock-data'
|
||||
import { mockOAuthTokenRequest, ToolTester } from '../__test-utils__/test-tools'
|
||||
import { gmailReadTool } from './read'
|
||||
|
||||
describe('Gmail Read Tool', () => {
|
||||
let tester: ToolTester
|
||||
let cleanupOAuth: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
tester = new ToolTester(gmailReadTool)
|
||||
// Mock OAuth token request
|
||||
cleanupOAuth = mockOAuthTokenRequest('gmail-access-token-123')
|
||||
// Set base URL environment variable
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tester.cleanup()
|
||||
cleanupOAuth()
|
||||
vi.resetAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = undefined
|
||||
})
|
||||
|
||||
describe('URL Construction', () => {
|
||||
test('should construct URL for reading a specific message', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg123',
|
||||
}
|
||||
|
||||
expect(tester.getRequestUrl(params)).toBe(
|
||||
'https://gmail.googleapis.com/gmail/v1/users/me/messages/msg123?format=full'
|
||||
)
|
||||
})
|
||||
|
||||
test('should construct URL for listing messages from inbox by default', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
expect(url).toContain('https://gmail.googleapis.com/gmail/v1/users/me/messages')
|
||||
expect(url).toContain('in:inbox')
|
||||
expect(url).toContain('maxResults=1')
|
||||
})
|
||||
|
||||
test('should construct URL for listing messages from specific folder', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
folder: 'SENT',
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
expect(url).toContain('in:sent')
|
||||
})
|
||||
|
||||
test('should construct URL with unread filter when specified', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
unreadOnly: true,
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
expect(url).toContain('is:unread')
|
||||
})
|
||||
|
||||
test('should respect maxResults parameter', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
maxResults: 5,
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
expect(url).toContain('maxResults=5')
|
||||
})
|
||||
|
||||
test('should limit maxResults to 10', () => {
|
||||
const params = {
|
||||
accessToken: 'test-token',
|
||||
maxResults: 20, // Should be limited to 10
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
expect(url).toContain('maxResults=10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authentication', () => {
|
||||
test('should include access token in headers', () => {
|
||||
const params = {
|
||||
accessToken: 'test-access-token',
|
||||
messageId: 'msg123',
|
||||
}
|
||||
|
||||
const headers = tester.getRequestHeaders(params)
|
||||
expect(headers.Authorization).toBe('Bearer test-access-token')
|
||||
expect(headers['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('should use OAuth credential when provided', async () => {
|
||||
// Setup initial response for message list
|
||||
tester.setup(mockGmailResponses.messageList)
|
||||
|
||||
// Then setup response for the first message
|
||||
const originalFetch = global.fetch
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation((url, options) => {
|
||||
// Check if it's a token request
|
||||
if (url.toString().includes('/api/auth/oauth/token')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ accessToken: 'gmail-access-token-123' }),
|
||||
})
|
||||
}
|
||||
|
||||
// For message list endpoint
|
||||
if (url.toString().includes('users/me/messages') && !url.toString().includes('msg1')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGmailResponses.messageList),
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// For specific message endpoint
|
||||
if (url.toString().includes('msg1')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGmailResponses.singleMessage),
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return originalFetch(url, options)
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
// Execute with credential instead of access token
|
||||
await tester.execute({
|
||||
credential: 'gmail-credential-id',
|
||||
})
|
||||
|
||||
// There's a mismatch in how the mocks are set up
|
||||
// The test setup makes only one fetch call in reality
|
||||
// This is okay for this test - we just want to test the credential flow
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
|
||||
// Restore original fetch
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
})
|
||||
|
||||
describe('Message Fetching', () => {
|
||||
test('should fetch a specific message by ID', async () => {
|
||||
// Setup mock response for single message
|
||||
tester.setup(mockGmailResponses.singleMessage)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check the result
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toBeDefined()
|
||||
expect(result.output.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'msg1',
|
||||
threadId: 'thread1',
|
||||
subject: 'Test Email Subject',
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('should fetch the first message from inbox by default', async () => {
|
||||
// Need to mock multiple sequential responses
|
||||
const originalFetch = global.fetch
|
||||
|
||||
// First setup response for message list
|
||||
global.fetch = Object.assign(
|
||||
vi
|
||||
.fn()
|
||||
.mockImplementationOnce((url, options) => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGmailResponses.messageList),
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
})
|
||||
})
|
||||
.mockImplementationOnce((url, options) => {
|
||||
// For the second request (first message)
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGmailResponses.singleMessage),
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
})
|
||||
|
||||
// Restore original fetch
|
||||
global.fetch = originalFetch
|
||||
|
||||
// Check the result
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toBeDefined()
|
||||
expect(result.output.metadata).toEqual({
|
||||
results: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('should handle empty inbox', async () => {
|
||||
// Setup mock response for empty list
|
||||
tester.setup(mockGmailResponses.emptyList)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
})
|
||||
|
||||
// Check the result
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toContain('No messages found')
|
||||
expect(result.output.metadata.results).toEqual([])
|
||||
})
|
||||
|
||||
test('should fetch multiple messages when maxResults > 1', async () => {
|
||||
// Need a completely controlled mock for this test
|
||||
const originalFetch = global.fetch
|
||||
|
||||
// Directly mock the transformResponse instead of trying to set up complex fetch chains
|
||||
const origTransformResponse = tester.tool.transformResponse
|
||||
tester.tool.transformResponse = async () => ({
|
||||
success: true,
|
||||
output: {
|
||||
content: 'Found 3 messages in your inbox',
|
||||
metadata: {
|
||||
results: [
|
||||
{ id: 'msg1', threadId: 'thread1', subject: 'Email 1' },
|
||||
{ id: 'msg2', threadId: 'thread2', subject: 'Email 2' },
|
||||
{ id: 'msg3', threadId: 'thread3', subject: 'Email 3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the tool with maxResults = 3
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
maxResults: 3,
|
||||
})
|
||||
|
||||
// Restore original implementation
|
||||
tester.tool.transformResponse = origTransformResponse
|
||||
global.fetch = originalFetch
|
||||
|
||||
// Check the result
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toContain('Found 3 messages')
|
||||
expect(result.output.metadata.results).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should handle invalid access token errors', async () => {
|
||||
// Setup error response
|
||||
tester.setup(
|
||||
{ error: { message: 'invalid authentication credentials' } },
|
||||
{ ok: false, status: 401 }
|
||||
)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'invalid-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check error handling
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle quota exceeded errors', async () => {
|
||||
// Setup error response
|
||||
tester.setup(
|
||||
{ error: { message: 'quota exceeded for quota metric' } },
|
||||
{ ok: false, status: 429 }
|
||||
)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check error handling
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle message not found errors', async () => {
|
||||
// Setup error response
|
||||
tester.setup({ error: { message: 'Resource not found' } }, { ok: false, status: 404 })
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'non-existent-msg',
|
||||
})
|
||||
|
||||
// Check error handling
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Extraction', () => {
|
||||
test('should extract plain text content from message', async () => {
|
||||
// Setup successful response
|
||||
tester.setup(mockGmailResponses.singleMessage)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check content extraction
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toBe('This is the plain text content of the email')
|
||||
})
|
||||
|
||||
test('should handle message with missing body', async () => {
|
||||
// Create a modified message with no body data
|
||||
const modifiedMessage = JSON.parse(JSON.stringify(mockGmailResponses.singleMessage))
|
||||
modifiedMessage.payload.parts[0].body.data = undefined
|
||||
modifiedMessage.payload.parts[1].body.data = undefined
|
||||
|
||||
// Setup the modified response
|
||||
tester.setup(modifiedMessage)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check content extraction fallback
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.content).toBe('No content found in email')
|
||||
})
|
||||
|
||||
test('should extract headers correctly', async () => {
|
||||
// Setup successful response
|
||||
tester.setup(mockGmailResponses.singleMessage)
|
||||
|
||||
// Execute the tool
|
||||
const result = await tester.execute({
|
||||
accessToken: 'test-token',
|
||||
messageId: 'msg1',
|
||||
})
|
||||
|
||||
// Check headers extraction
|
||||
expect(result.output.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Email Subject',
|
||||
date: 'Mon, 15 Mar 2025 10:30:00 -0800',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,36 +12,38 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-email',
|
||||
additionalScopes: [
|
||||
// 'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
additionalScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Access token for Gmail API',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'ID of the message to read',
|
||||
},
|
||||
folder: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Folder/label to read emails from',
|
||||
},
|
||||
unreadOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Only retrieve unread messages',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Maximum number of messages to retrieve (default: 1, max: 10)',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,26 +12,26 @@ export const gmailSearchTool: ToolConfig<GmailSearchParams, GmailToolResponse> =
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-email',
|
||||
additionalScopes: [
|
||||
// 'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
additionalScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Access token for Gmail API',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search query for emails',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Maximum number of results to return',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,21 +19,25 @@ export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Access token for Gmail API',
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Recipient email address',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email subject',
|
||||
},
|
||||
body: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email body content',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user