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:
Waleed Latif
2025-07-13 20:16:49 -07:00
committed by GitHub
parent db22e26662
commit ff2b1d33c8
223 changed files with 4089 additions and 4920 deletions

View File

@@ -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',
},
},

View File

@@ -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',
})
)
})
})
})

View File

@@ -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)',
},
},

View File

@@ -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',
},
},

View File

@@ -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',
},
},