Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
507 changes: 485 additions & 22 deletions apps/docs/content/docs/integrations/microsoft_excel.mdx

Large diffs are not rendered by default.

1,310 changes: 1,116 additions & 194 deletions apps/sim/blocks/blocks/microsoft_excel.ts

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'microsoft-excel',
icon: MicrosoftExcelIcon,
baseProviderIcon: MicrosoftIcon,
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
scopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'Sites.Read.All',
'offline_access',
],
},
'microsoft-intune': {
name: 'Microsoft Intune',
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/workflows/migrations/subblock-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ function isFieldProjection(value: unknown): boolean {
* secret onto a live subblock.
*/
export const SUBBLOCK_ID_MIGRATIONS: Record<string, readonly SubblockIdMigration[]> = {
microsoft_excel: [{ from: 'valueInputOption', to: '_removed_valueInputOption' }],
microsoft_excel_v2: [
{ from: 'valueInputOption', to: '_removed_valueInputOption' },
{ from: 'sortTableName', to: 'tableName', whenOperation: ['sort_range'] },
],
/** MCP normalization selects the active replacement before this rename pass. */
mcp: [
{ from: 'server', to: 'serverSelector' },
Expand Down
114 changes: 114 additions & 0 deletions apps/sim/providers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock('@/lib/internal/workflows/read-tool-enrichment', () => ({
}))

import { IntuneBlock } from '@/blocks/blocks/intune'
import { MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel'
import { RampBlock } from '@/blocks/blocks/ramp'
import { RevenueCatBlock } from '@/blocks/blocks/revenuecat'
import { VideoGeneratorV3Block } from '@/blocks/blocks/video_generator'
Expand Down Expand Up @@ -44,6 +45,9 @@ import {
import { useProvidersStore } from '@/stores/providers/store'
import { intuneListDetectedAppsTool } from '@/tools/intune/list-detected-apps'
import { intuneListDevicesTool } from '@/tools/intune/list-devices'
import { clearRangeTool } from '@/tools/microsoft_excel/clear_range'
import { createTableTool } from '@/tools/microsoft_excel/create_table'
import { sortRangeTool } from '@/tools/microsoft_excel/sort_range'
import { rampListReimbursementsTool } from '@/tools/ramp/list-reimbursements'
import { rampListTransactionsTool } from '@/tools/ramp/list-transactions'
import { revenuecatGetCustomerTool } from '@/tools/revenuecat/get_customer'
Expand Down Expand Up @@ -178,6 +182,116 @@ describe('integration filters through agent execution', () => {
})
})

describe('Excel targets through agent execution', () => {
it.each(['model', 'configured'] as const)(
'clears the %s range after canonical argument mapping',
async (source) => {
const providerTool = await transformBlockTool(
{
type: 'microsoft_excel_v2',
params: {
manualSpreadsheetId: 'workbook1',
manualSheetName: 'Sheet1',
...(source === 'configured' ? { cellRange: 'B2:D5' } : {}),
},
},
{
selectedOperation: 'clear_range',
canonicalModes: { '0:spreadsheetId': 'advanced', '0:sheetName': 'advanced' },
toolIndex: 0,
getAllBlocks: () => [MicrosoftExcelV2Block],
getTool: () => clearRangeTool,
}
)
if (!providerTool) throw new Error('Excel agent tool was not created')
const { executionParams } = prepareToolExecution(
providerTool,
{ range: 'A1:C4' },
{},
undefined
)
const params = {
...executionParams,
accessToken: 'fixture-token',
spreadsheetId: executionParams.spreadsheetId,
range: executionParams.range,
}
const url =
typeof clearRangeTool.request.url === 'function'
? clearRangeTool.request.url(params)
: clearRangeTool.request.url
expect(decodeURIComponent(url)).toContain(
`range(address='${source === 'configured' ? 'B2:D5' : 'A1:C4'}')/clear`
)
}
)

it('sorts the model-supplied table instead of an unused range', async () => {
const providerTool = await transformBlockTool(
{
type: 'microsoft_excel_v2',
params: { manualSpreadsheetId: 'workbook1', manualSheetName: 'Sheet1', cellRange: 'A1:C4' },
},
{
selectedOperation: 'sort_range',
canonicalModes: { '0:spreadsheetId': 'advanced', '0:sheetName': 'advanced' },
toolIndex: 0,
getAllBlocks: () => [MicrosoftExcelV2Block],
getTool: () => sortRangeTool,
}
)
if (!providerTool) throw new Error('Excel agent tool was not created')
const { executionParams } = prepareToolExecution(
providerTool,
{ tableName: 'Inventory', sortColumn: 0, matchCase: true },
{},
undefined
)
const params = {
...executionParams,
accessToken: 'fixture-token',
spreadsheetId: executionParams.spreadsheetId,
}
const url =
typeof sortRangeTool.request.url === 'function'
? sortRangeTool.request.url(params)
: sortRangeTool.request.url
expect(decodeURIComponent(url)).toContain("tables('Inventory')/sort/apply")
expect(sortRangeTool.request.body?.(params)).toMatchObject({ matchCase: true })
})

it('creates a table without converting an explicit false header flag to true', async () => {
const providerTool = await transformBlockTool(
{
type: 'microsoft_excel_v2',
params: { manualSpreadsheetId: 'workbook1', manualSheetName: 'Sheet1', cellRange: 'A1:C4' },
},
{
selectedOperation: 'create_table',
canonicalModes: { '0:spreadsheetId': 'advanced', '0:sheetName': 'advanced' },
toolIndex: 0,
getAllBlocks: () => [MicrosoftExcelV2Block],
getTool: () => createTableTool,
}
)
if (!providerTool) throw new Error('Excel agent tool was not created')
const { executionParams } = prepareToolExecution(
providerTool,
{ hasHeaders: false },
{},
undefined
)
expect(
createTableTool.request.body?.({
...executionParams,
accessToken: 'fixture-token',
spreadsheetId: executionParams.spreadsheetId,
address: executionParams.address,
})
).toMatchObject({ hasHeaders: false })
})
})

afterAll(resetEnvFlagsMock)

describe('getApiKey', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-ids.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-outputs.ts

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions apps/sim/tools/microsoft_excel/calculate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import {
assertExcelSuccess,
EXCEL_WORKBOOK_PARAMS,
excelHeaders,
excelWorkbookUrl,
} from '@/tools/microsoft_excel/shared'
import type { ExcelCalculateParams, ExcelCalculateResponse } from '@/tools/microsoft_excel/types'
import type { ToolConfig } from '@/tools/types'
export const calculateTool: ToolConfig<ExcelCalculateParams, ExcelCalculateResponse> = {
id: 'microsoft_excel_calculate',
name: 'Microsoft Excel Calculate',
description: 'Calculate in a Microsoft Excel workbook.',
version: '1.0.0',
oauth: { required: true, provider: 'microsoft-excel' },
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
params: {
...EXCEL_WORKBOOK_PARAMS,
calculationType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Recalculate recalculates dirty cells; Full recalculates all cells; FullRebuild rebuilds dependencies and recalculates all cells.',
},
},
request: {
url: (params) => `${excelWorkbookUrl(params)}/application/calculate`,
method: 'POST',
headers: excelHeaders,
body: (params) => {
if (!['Recalculate', 'Full', 'FullRebuild'].includes(params.calculationType))
throw new Error('Invalid calculation type')
return { calculationType: params.calculationType }
},
},
transformResponse: async (response) => {
await assertExcelSuccess(response)
return { success: true, output: { calculated: true } }
},
outputs: {
calculated: { type: 'boolean', description: 'Whether workbook calculation completed.' },
},
}
29 changes: 16 additions & 13 deletions apps/sim/tools/microsoft_excel/clear_range.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import {
assertExcelSuccess,
EXCEL_WORKBOOK_PARAMS,
excelHeaders,
} from '@/tools/microsoft_excel/shared'
import type {
MicrosoftExcelClearRangeParams,
MicrosoftExcelClearRangeResponse,
Expand All @@ -8,7 +13,7 @@ import {
getItemBasePath,
getSpreadsheetWebUrl,
} from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
import type { ToolConfig, ToolResponseContext } from '@/tools/types'

/**
* Clears the contents and/or formatting of a worksheet range.
Expand All @@ -30,6 +35,7 @@ export const clearRangeTool: ToolConfig<
},

params: {
sessionId: EXCEL_WORKBOOK_PARAMS.sessionId,
accessToken: {
type: 'string',
required: true,
Expand All @@ -47,7 +53,7 @@ export const clearRangeTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses the signed-in user’s default OneDrive. Excel workbook APIs require a business account.',
},
sheetName: {
type: 'string',
Expand Down Expand Up @@ -80,29 +86,26 @@ export const clearRangeTool: ToolConfig<
return `${buildWorksheetRangeUrl(basePath, params.range, params.sheetName)}/clear`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
headers: excelHeaders,
body: (params) => ({
applyTo: params.applyTo || 'All',
}),
},

transformResponse: async (_response: Response, params?: MicrosoftExcelClearRangeParams) => {
transformResponse: async (
response: Response,
params?: MicrosoftExcelClearRangeParams,
context?: ToolResponseContext
) => {
await assertExcelSuccess(response)
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId

const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId, context?.signal)

return {
success: true,
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/tools/microsoft_excel/close_session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import {
assertExcelSuccess,
EXCEL_WORKBOOK_PARAMS,
excelHeaders,
excelWorkbookUrl,
} from '@/tools/microsoft_excel/shared'
import type { ExcelCloseSessionResponse, ExcelSessionParams } from '@/tools/microsoft_excel/types'
import type { ToolConfig } from '@/tools/types'
export const closeSessionTool: ToolConfig<ExcelSessionParams, ExcelCloseSessionResponse> = {
id: 'microsoft_excel_close_session',
name: 'Microsoft Excel Close Session',
description: 'Close Session in a Microsoft Excel workbook.',
version: '1.0.0',
oauth: { required: true, provider: 'microsoft-excel' },
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
params: {
...EXCEL_WORKBOOK_PARAMS,
sessionId: { ...EXCEL_WORKBOOK_PARAMS.sessionId, required: true },
},
request: {
url: (params) => {
if (!params.sessionId?.trim()) throw new Error('Session ID is required')
return `${excelWorkbookUrl(params)}/closeSession`
},
method: 'POST',
headers: excelHeaders,
},
transformResponse: async (response) => {
await assertExcelSuccess(response)
return { success: true, output: { closed: true } }
},
outputs: { closed: { type: 'boolean', description: 'Whether the workbook session was closed.' } },
}
51 changes: 51 additions & 0 deletions apps/sim/tools/microsoft_excel/create_session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import {
EXCEL_WORKBOOK_PARAMS,
excelHeaders,
excelWorkbookUrl,
readExcelJson,
} from '@/tools/microsoft_excel/shared'
import type {
ExcelCreateSessionParams,
ExcelCreateSessionResponse,
} from '@/tools/microsoft_excel/types'
import type { ToolConfig } from '@/tools/types'
export const createSessionTool: ToolConfig<ExcelCreateSessionParams, ExcelCreateSessionResponse> = {
id: 'microsoft_excel_create_session',
name: 'Microsoft Excel Create Session',
description: 'Create Session in a Microsoft Excel workbook.',
version: '1.0.0',
oauth: { required: true, provider: 'microsoft-excel' },
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
params: {
accessToken: EXCEL_WORKBOOK_PARAMS.accessToken,
spreadsheetId: EXCEL_WORKBOOK_PARAMS.spreadsheetId,
driveId: EXCEL_WORKBOOK_PARAMS.driveId,
persistChanges: {
type: 'boolean',
required: true,
visibility: 'user-only',
description:
'True saves changes; false creates a temporary workbook copy whose changes are discarded when the session ends.',
},
},
request: {
url: (params) => `${excelWorkbookUrl(params)}/createSession`,
method: 'POST',
headers: excelHeaders,
body: (params) => ({ persistChanges: params.persistChanges }),
},
transformResponse: async (response) => {
const data = await readExcelJson(response)
if (typeof data.id !== 'string' || typeof data.persistChanges !== 'boolean')
throw new Error('Microsoft Graph did not return a workbook session')
return { success: true, output: { sessionId: data.id, persistChanges: data.persistChanges } }
},
outputs: {
sessionId: {
type: 'string',
description: 'Pass this ID as sessionId on subsequent workbook operations.',
},
persistChanges: { type: 'boolean', description: 'Whether changes in this session are saved.' },
},
}
Loading
Loading