From 2112b2d7c6e2413bc52e1ea10c9871ae4931b583 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 5 May 2026 20:54:14 +0300 Subject: [PATCH 1/4] feat(chat): add server-side tool approval for destructive actions (#13104) --- .../public/locales/de/translation.json | 3 + .../public/locales/en/translation.json | 3 + .../public/locales/es/translation.json | 3 + .../public/locales/fr/translation.json | 3 + .../public/locales/ja/translation.json | 3 + .../public/locales/nl/translation.json | 3 + .../public/locales/pt/translation.json | 3 + .../public/locales/zh-TW/translation.json | 3 + .../public/locales/zh/translation.json | 3 + .../api/src/app/chat/chat-approval-gate.ts | 54 +++++++++ .../api/src/app/chat/chat-controller.ts | 32 ++++- .../server/api/src/app/chat/chat-service.ts | 92 +++++++------- .../server/api/src/app/chat/mcp/chat-mcp.ts | 78 +++++++++++- .../api/src/app/mcp/tools/ap-delete-branch.ts | 1 + .../src/app/mcp/tools/ap-delete-records.ts | 1 + .../api/src/app/mcp/tools/ap-delete-step.ts | 1 + .../api/src/app/mcp/tools/ap-delete-table.ts | 1 + .../api/src/app/mcp/tools/ap-test-flow.ts | 3 +- .../api/src/app/mcp/tools/ap-test-step.ts | 3 +- .../src/assets/prompts/chat-system-prompt.md | 4 +- packages/shared/package.json | 2 +- .../web/public/locales/en/translation.json | 3 + .../app/routes/chat-with-ai/ai-chat-box.tsx | 24 +++- .../components/tool-approval-form.tsx | 114 ++++++++++++++++++ .../components/tool-call-group.tsx | 8 +- .../chat/components/tool-call-card.tsx | 62 +--------- .../web/src/features/chat/lib/chat-utils.ts | 79 ++++++++++++ .../web/src/features/chat/lib/use-chat.ts | 31 ++++- .../features/chat/lib/use-tool-approval.ts | 97 +++++++++++++++ 29 files changed, 591 insertions(+), 126 deletions(-) create mode 100644 packages/server/api/src/app/chat/chat-approval-gate.ts create mode 100644 packages/web/src/app/routes/chat-with-ai/components/tool-approval-form.tsx create mode 100644 packages/web/src/features/chat/lib/chat-utils.ts create mode 100644 packages/web/src/features/chat/lib/use-tool-approval.ts diff --git a/packages/react-ui/public/locales/de/translation.json b/packages/react-ui/public/locales/de/translation.json index 508019580b7..3e97abcc1a5 100644 --- a/packages/react-ui/public/locales/de/translation.json +++ b/packages/react-ui/public/locales/de/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/en/translation.json b/packages/react-ui/public/locales/en/translation.json index ac327e46fdb..5e77df512ec 100644 --- a/packages/react-ui/public/locales/en/translation.json +++ b/packages/react-ui/public/locales/en/translation.json @@ -477,6 +477,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/es/translation.json b/packages/react-ui/public/locales/es/translation.json index 8332f429b98..32f20d37a17 100644 --- a/packages/react-ui/public/locales/es/translation.json +++ b/packages/react-ui/public/locales/es/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/fr/translation.json b/packages/react-ui/public/locales/fr/translation.json index 8332f429b98..32f20d37a17 100644 --- a/packages/react-ui/public/locales/fr/translation.json +++ b/packages/react-ui/public/locales/fr/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/ja/translation.json b/packages/react-ui/public/locales/ja/translation.json index 944802abcc4..2324c24f4b4 100644 --- a/packages/react-ui/public/locales/ja/translation.json +++ b/packages/react-ui/public/locales/ja/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/nl/translation.json b/packages/react-ui/public/locales/nl/translation.json index 508019580b7..3e97abcc1a5 100644 --- a/packages/react-ui/public/locales/nl/translation.json +++ b/packages/react-ui/public/locales/nl/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/pt/translation.json b/packages/react-ui/public/locales/pt/translation.json index 8332f429b98..32f20d37a17 100644 --- a/packages/react-ui/public/locales/pt/translation.json +++ b/packages/react-ui/public/locales/pt/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/zh-TW/translation.json b/packages/react-ui/public/locales/zh-TW/translation.json index 944802abcc4..2324c24f4b4 100644 --- a/packages/react-ui/public/locales/zh-TW/translation.json +++ b/packages/react-ui/public/locales/zh-TW/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/react-ui/public/locales/zh/translation.json b/packages/react-ui/public/locales/zh/translation.json index 944802abcc4..2324c24f4b4 100644 --- a/packages/react-ui/public/locales/zh/translation.json +++ b/packages/react-ui/public/locales/zh/translation.json @@ -479,6 +479,9 @@ "Invalid Access": "", "You tried to access a project that you do not have access to.": "", "New Table": "", + "No, cancel": "", + "Yes, and don't ask me again": "", + "Yes, proceed": "", "Response stopped": "", "Retry": "", "Reply...": "", diff --git a/packages/server/api/src/app/chat/chat-approval-gate.ts b/packages/server/api/src/app/chat/chat-approval-gate.ts new file mode 100644 index 00000000000..c5a737524b3 --- /dev/null +++ b/packages/server/api/src/app/chat/chat-approval-gate.ts @@ -0,0 +1,54 @@ +import { redisConnections } from '../database/redis-connections' +import { pubsub } from '../helper/pubsub' + +const GATE_TIMEOUT_MS = 5 * 60 * 1000 +const CHANNEL_PREFIX = 'tool-approval:' + +function channelName(gateId: string): string { + return `${CHANNEL_PREFIX}${gateId}` +} + +async function waitForApproval({ gateId }: { gateId: string }): Promise { + const channel = channelName(gateId) + const subscriber = await redisConnections.create() + + return new Promise((resolve) => { + let settled = false + + const cleanup = () => { + if (settled) return + settled = true + subscriber.unsubscribe(channel).then(() => subscriber.quit()).catch(() => undefined) + } + + const timeout = setTimeout(() => { + cleanup() + resolve(false) + }, GATE_TIMEOUT_MS) + + subscriber.on('message', (_ch, message) => { + if (_ch !== channel) return + clearTimeout(timeout) + cleanup() + try { + const parsed = JSON.parse(message) + resolve(parsed.approved === true) + } + catch { + resolve(false) + } + }) + + void subscriber.subscribe(channel) + }) +} + +async function resolveGate({ gateId, approved }: { gateId: string, approved: boolean }): Promise { + const channel = channelName(gateId) + await pubsub.publish(channel, JSON.stringify({ approved })) +} + +export const chatApprovalGate = { + waitForApproval, + resolveGate, +} diff --git a/packages/server/api/src/app/chat/chat-controller.ts b/packages/server/api/src/app/chat/chat-controller.ts index e0132f20154..32accf834a2 100644 --- a/packages/server/api/src/app/chat/chat-controller.ts +++ b/packages/server/api/src/app/chat/chat-controller.ts @@ -6,10 +6,12 @@ import { SetProjectContextRequest, UpdateChatConversationRequest, } from '@activepieces/shared' +import { pipeUIMessageStreamToResponse } from 'ai' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' import { securityAccess } from '../core/security/authorization/fastify-security' +import { chatApprovalGate } from './chat-approval-gate' import { chatService } from './chat-service' const CHAT_PRINCIPALS = [PrincipalType.USER] as const @@ -72,7 +74,7 @@ export const chatController: FastifyPluginAsyncZod = async (app) => { const { content, files } = request.body const log = request.log - const { result, closeMcpClient } = await chatService(log).sendMessage({ + const { stream, closeMcpClient } = await chatService(log).sendMessage({ conversationId: request.params.id, userId: request.principal.id, platformId: request.principal.platform.id, @@ -83,12 +85,16 @@ export const chatController: FastifyPluginAsyncZod = async (app) => { await reply.hijack() try { - result.pipeUIMessageStreamToResponse(reply.raw, { + pipeUIMessageStreamToResponse({ + response: reply.raw, + stream, headers: { 'X-Accel-Buffering': 'no', }, }) - await result.consumeStream() + await new Promise((resolve) => { + reply.raw.on('close', resolve) + }) } catch (err: unknown) { const isClientDisconnect = err instanceof Error && 'code' in err && err.code === 'ECONNRESET' @@ -104,6 +110,14 @@ export const chatController: FastifyPluginAsyncZod = async (app) => { } }) + app.post('/tool-approvals/:gateId', ToolApprovalRoute, async (request, reply) => { + await chatApprovalGate.resolveGate({ + gateId: request.params.gateId, + approved: request.body.approved, + }) + return reply.status(StatusCodes.OK).send({ success: true }) + }) + app.post('/conversations/:id/project-context', SetProjectContextRoute, async (request) => { return chatService(request.log).setProjectContext({ id: request.params.id, @@ -198,6 +212,18 @@ const SendMessageRoute = { }, } +const ToolApprovalRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['chat'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: z.object({ gateId: z.string() }), + body: z.object({ approved: z.boolean() }), + }, +} + const SetProjectContextRoute = { config: { security: securityAccess.publicPlatform(CHAT_PRINCIPALS), diff --git a/packages/server/api/src/app/chat/chat-service.ts b/packages/server/api/src/app/chat/chat-service.ts index 1daa432b559..81f4abc5b2a 100644 --- a/packages/server/api/src/app/chat/chat-service.ts +++ b/packages/server/api/src/app/chat/chat-service.ts @@ -1,4 +1,3 @@ -import { ServerResponse } from 'http' import { ActivepiecesError, AIProviderModelType, @@ -16,7 +15,7 @@ import { spreadIfDefined, UpdateChatConversationRequest, } from '@activepieces/shared' -import { LanguageModel, ModelMessage, stepCountIs, streamText } from 'ai' +import { createUIMessageStream, LanguageModel, ModelMessage, stepCountIs, streamText } from 'ai' import { FastifyBaseLogger } from 'fastify' import { aiProviderService } from '../ai/ai-provider-service' import { repoFactory } from '../core/db/repo-factory' @@ -197,7 +196,6 @@ export const chatService = (log: FastifyBaseLogger) => ({ }, availableProjectIds: userProjects.map((p) => p.id), }) - const tools = { ...localTools, ...mcpToolSet } const closeMcpClient = async (): Promise => { if (mcpClient) { @@ -207,47 +205,50 @@ export const chatService = (log: FastifyBaseLogger) => ({ } } - try { - const result = streamText({ - model, - system: systemPrompt, - messages: messagesForLlm, - tools, - stopWhen: stepCountIs(MAX_STEPS), - onStepFinish: ({ finishReason, usage }) => { - log.debug({ conversationId, finishReason, usage }, 'Chat step finished') - }, - onFinish: async ({ response, usage }) => { - const updatedMessages = [...allMessages, ...response.messages] - try { - await conversationRepo().update(conversationId, { - messages: updatedMessages, - ...(pendingTitle ? { title: pendingTitle } : {}), - ...(isNil(conversation.modelName) ? { modelName } : {}), - }) - } - catch (saveErr) { - log.error({ err: saveErr, conversationId }, 'Failed to persist conversation messages') - } - - log.info({ - conversationId, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - provider: providerConfig.provider, - }, 'Chat message completed') - }, - onError: ({ error }) => { - log.error({ err: error, conversationId }, 'Chat streamText error') - }, - }) + const stream = createUIMessageStream({ + execute: ({ writer }) => { + const gatedTools = chatMcp.withApprovalGates({ mcpToolSet, writer, log }) + const tools = { ...localTools, ...gatedTools } + + const textStream = streamText({ + model, + system: systemPrompt, + messages: messagesForLlm, + tools, + stopWhen: stepCountIs(MAX_STEPS), + onStepFinish: ({ finishReason, usage }) => { + log.debug({ conversationId, finishReason, usage }, 'Chat step finished') + }, + onFinish: async ({ response, usage }) => { + const updatedMessages = [...allMessages, ...response.messages] + try { + await conversationRepo().update(conversationId, { + messages: updatedMessages, + ...(pendingTitle ? { title: pendingTitle } : {}), + ...(isNil(conversation.modelName) ? { modelName } : {}), + }) + } + catch (saveErr) { + log.error({ err: saveErr, conversationId }, 'Failed to persist conversation messages') + } + + log.info({ + conversationId, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + provider: providerConfig.provider, + }, 'Chat message completed') + }, + onError: ({ error }) => { + log.error({ err: error, conversationId }, 'Chat streamText error') + }, + }) - return { result, closeMcpClient } - } - catch (err) { - await closeMcpClient() - throw err - } + writer.merge(textStream.toUIMessageStream()) + }, + }) + + return { stream, closeMcpClient } }, }) @@ -386,9 +387,6 @@ type SendMessageParams = { } type SendMessageResult = { - result: { - pipeUIMessageStreamToResponse(response: ServerResponse, options?: Record): void - consumeStream(): PromiseLike - } + stream: ReadableStream closeMcpClient: () => Promise } diff --git a/packages/server/api/src/app/chat/mcp/chat-mcp.ts b/packages/server/api/src/app/chat/mcp/chat-mcp.ts index 1e27570c5be..2f3178545a8 100644 --- a/packages/server/api/src/app/chat/mcp/chat-mcp.ts +++ b/packages/server/api/src/app/chat/mcp/chat-mcp.ts @@ -1,9 +1,14 @@ -import { isNil, tryCatch } from '@activepieces/shared' +import { apId, isNil, tryCatch } from '@activepieces/shared' import { createMCPClient } from '@ai-sdk/mcp' import { FastifyBaseLogger } from 'fastify' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' import { mcpOAuthTokenService } from '../../mcp/oauth/token/mcp-oauth-token.service' +import { chatApprovalGate } from '../chat-approval-gate' + +type StreamWriter = { + write(part: Record): void +} async function getMcpCredentials({ platformId, userId, log }: { platformId: string @@ -46,6 +51,76 @@ async function connectMcpClient({ mcpCredentials, log }: { return { mcpClient: client, mcpToolSet } } +const AP_TOOLS_REQUIRING_APPROVAL = new Set([ + 'ap_delete_table', + 'ap_delete_step', + 'ap_delete_branch', + 'ap_delete_records', + 'ap_run_action', + 'ap_test_step', + 'ap_test_flow', + 'ap_change_flow_status', +]) + +function humanizeToolName(name: string): string { + return name + .replace(/^ap_/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function requiresApproval(name: string): boolean { + return AP_TOOLS_REQUIRING_APPROVAL.has(name) || !name.startsWith('ap_') +} + +function hasExecute(tool: object): tool is object & { execute: (args: unknown) => Promise } { + return 'execute' in tool && typeof tool.execute === 'function' +} + +function withApprovalGates({ mcpToolSet, writer, log }: { + mcpToolSet: Record + writer: StreamWriter + log: FastifyBaseLogger +}): Record { + const result: Record = {} + + for (const [name, tool] of Object.entries(mcpToolSet)) { + if (!requiresApproval(name) || typeof tool !== 'object' || tool === null || !hasExecute(tool)) { + result[name] = tool + continue + } + + const originalExecute = tool.execute.bind(tool) + result[name] = Object.assign({}, tool, { + execute: async (args: unknown) => { + const gateId = apId() + const displayName = typeof args === 'object' && args !== null && 'displayName' in args && typeof args.displayName === 'string' + ? args.displayName + : humanizeToolName(name) + + writer.write({ + type: 'data-approval-request', + data: { gateId, toolName: name, displayName }, + transient: true, + }) + + log.info({ gateId, toolName: name }, 'Tool approval gate opened — waiting for user') + const approved = await chatApprovalGate.waitForApproval({ gateId }) + + if (!approved) { + log.info({ gateId, toolName: name }, 'Tool approval rejected or timed out') + return { content: [{ type: 'text', text: 'Action cancelled by user.' }] } + } + + log.info({ gateId, toolName: name }, 'Tool approval granted — executing') + return originalExecute(args) + }, + }) + } + + return result +} + type McpCredentials = { mcpServerUrl: string | null mcpToken: string | null @@ -59,4 +134,5 @@ type McpConnection = { export const chatMcp = { getCredentials: getMcpCredentials, connectClient: connectMcpClient, + withApprovalGates, } diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts b/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts index 171680c521c..9e62a65460e 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts @@ -27,6 +27,7 @@ export const apDeleteBranchTool = (mcp: ProjectScopedMcpServer, log: FastifyBase flowId: z.string().describe('The id of the flow'), routerStepName: z.string().describe('The name of the ROUTER step. Use ap_flow_structure to get valid values.'), branchIndex: z.number().describe('The index of the branch to delete (0-based). Cannot delete the fallback/last branch.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete branch 2 from router"). Must include what the action does and the target name.'), }, annotations: { destructiveHint: true, openWorldHint: false }, execute: async (args) => { diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts index 31ee586a99f..b46b1e48726 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts @@ -6,6 +6,7 @@ import { mcpUtils } from './mcp-utils' const deleteRecordsInput = z.object({ recordIds: z.array(z.string()).describe('Array of record IDs to delete. Use ap_find_records to find them.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete 3 records from Emails table"). Must include what the action does and the target name.'), }) export const apDeleteRecordsTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogger): McpToolDefinition => { diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-step.ts b/packages/server/api/src/app/mcp/tools/ap-delete-step.ts index f537f40d20a..bd4ffd5120f 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-step.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-step.ts @@ -26,6 +26,7 @@ export const apDeleteStepTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLo inputSchema: { flowId: z.string().describe('The id of the flow'), stepName: z.string().describe('The name of the step to delete. Use ap_flow_structure to get valid values.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete Send Email step"). Must include what the action does and the target name.'), }, annotations: { destructiveHint: true, openWorldHint: false }, execute: async (args) => { diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-table.ts b/packages/server/api/src/app/mcp/tools/ap-delete-table.ts index 3564bc01782..1bbd5d43fd3 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-table.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-table.ts @@ -6,6 +6,7 @@ import { mcpUtils } from './mcp-utils' const deleteTableInput = z.object({ tableId: z.string().describe('The ID of the table to delete. Use ap_list_tables to find it.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete Customer Emails table"). Must include what the action does and the target name.'), }) export const apDeleteTableTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogger): McpToolDefinition => { diff --git a/packages/server/api/src/app/mcp/tools/ap-test-flow.ts b/packages/server/api/src/app/mcp/tools/ap-test-flow.ts index 47cf27bd375..62503a9be0e 100644 --- a/packages/server/api/src/app/mcp/tools/ap-test-flow.ts +++ b/packages/server/api/src/app/mcp/tools/ap-test-flow.ts @@ -6,6 +6,7 @@ import { mcpUtils } from './mcp-utils' const testFlowInput = z.object({ flowId: z.string().describe('The ID of the flow to test. Use ap_list_flows to find it.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Test Send Welcome Email"). Must include what the action does and the target name.'), triggerTestData: z.record(z.string(), z.unknown()).optional().describe('Mock trigger output data. Saved as sample data before running the test. Useful when the trigger has no prior test data.'), }) @@ -15,7 +16,7 @@ export const apTestFlowTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogg permission: Permission.WRITE_FLOW, description: 'Test a flow end-to-end in the test environment. Requires a configured trigger. Waits up to 120s. Pass triggerTestData to provide mock trigger output when no sample data exists.', inputSchema: testFlowInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { flowId, triggerTestData } = testFlowInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-test-step.ts b/packages/server/api/src/app/mcp/tools/ap-test-step.ts index 978090a65e1..c5a36d4c024 100644 --- a/packages/server/api/src/app/mcp/tools/ap-test-step.ts +++ b/packages/server/api/src/app/mcp/tools/ap-test-step.ts @@ -7,6 +7,7 @@ import { mcpUtils } from './mcp-utils' const testStepInput = z.object({ flowId: z.string().describe('The ID of the flow containing the step. Use ap_list_flows to find it.'), stepName: z.string().describe('The name of the step to test (e.g., "step_1"). Use ap_flow_structure to find it.'), + displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Test Send Email step in Welcome Flow"). Must include what the action does and the target name.'), triggerTestData: z.record(z.string(), z.unknown()).optional().describe('Mock trigger output data. Saved as sample data before running the test. Useful when the trigger has no prior test data.'), }) @@ -16,7 +17,7 @@ export const apTestStepTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogg permission: Permission.WRITE_FLOW, description: 'Test a single step within a flow. Runs all steps up to and including the specified step. The flow must have a configured trigger. Pass triggerTestData when no sample data exists.', inputSchema: testStepInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { flowId, stepName, triggerTestData } = testStepInput.parse(args) diff --git a/packages/server/api/src/assets/prompts/chat-system-prompt.md b/packages/server/api/src/assets/prompts/chat-system-prompt.md index e193f8647d1..ca3d88329a1 100644 --- a/packages/server/api/src/assets/prompts/chat-system-prompt.md +++ b/packages/server/api/src/assets/prompts/chat-system-prompt.md @@ -26,8 +26,8 @@ You have access to tools for reading data, building automations, managing tables Tool risk levels: - **Read-only** (ap_list_flows, ap_list_connections, ap_find_records, ap_flow_structure, ap_list_runs, ap_get_run): Use freely. No confirmation needed. - **Write** (ap_create_flow, ap_add_step, ap_update_trigger, ap_insert_records, ap_manage_fields): Use after the user approves a proposal or explicitly requests the action. -- **Destructive** (ap_delete_step, ap_delete_table, ap_delete_records, ap_change_flow_status): Always confirm before executing. List what will be affected, show "Yes, proceed" / "Cancel" quick-replies, and wait. -- **Connection-bound** (ap_run_action, ap_test_step — anything that sends data through an external service): Always show a confirmation card first: what action, which connection, which project. +- **Destructive** (ap_delete_step, ap_delete_table, ap_delete_records, ap_change_flow_status): The system will automatically prompt the user for approval before executing. Do NOT add your own confirmation — just call the tool directly when the user asks. +- **Connection-bound** (ap_run_action, ap_test_step, ap_test_flow — anything that sends data through an external service): The system will automatically prompt the user for approval before executing. Do NOT add your own confirmation — just call the tool directly. Error handling: - If a tool call fails, retry ONCE silently. diff --git a/packages/shared/package.json b/packages/shared/package.json index b2410234171..e2e4bcc5414 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.71.2", + "version": "0.71.3", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index e0afc5fbdd9..83d19387e40 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1582,6 +1582,9 @@ "Private Chat": "Private Chat", "Ready to use": "Ready to use", "Regenerate": "Regenerate", + "No, cancel": "No, cancel", + "Yes, and don't ask me again": "Yes, and don't ask me again", + "Yes, proceed": "Yes, proceed", "Response stopped": "Response stopped", "Sandbox not configured": "Sandbox not configured", "Set up an AI provider to get started": "Set up an AI provider to get started", diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index ece7f9053ea..63610458424 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -12,6 +12,7 @@ import { import { ScrollButton } from '@/components/prompt-kit/scroll-button'; import { Button } from '@/components/ui/button'; import { useAgentChat } from '@/features/chat/lib/use-chat'; +import { useToolApproval } from '@/features/chat/lib/use-tool-approval'; import { aiProviderQueries } from '@/features/platform-admin'; import { projectCollectionUtils } from '@/features/projects'; @@ -27,6 +28,7 @@ import { ChatModelSelector } from './components/chat-model-selector'; import { ChatProjectSelector } from './components/chat-project-selector'; import { QuickReplies } from './components/message-content'; import { MultiQuestionForm } from './components/multi-question-form'; +import { ToolApprovalForm } from './components/tool-approval-form'; import { getTextFromParts, parseMultiQuestion, @@ -80,6 +82,7 @@ function ChatBoxContent({ setConversationId, setModelName, setProjectContext, + pendingApprovalRequest, } = useAgentChat({ onTitleUpdate, onConversationCreated }); const { data: allProjects } = projectCollectionUtils.useAll(); const projects = allProjects ?? []; @@ -130,6 +133,16 @@ function ChatBoxContent({ activeQuestions.length > 0 && !!lastMessage && !dismissedFormIds.has(lastMessage.id); + + const { + hasActiveApproval, + approvalDisplayName, + approve, + approveAndRemember, + reject, + dismiss: dismissApproval, + } = useToolApproval({ pendingApprovalRequest }); + const isEmpty = messages.length === 0 && !isLoadingHistory && !isStreaming; if (isEmpty) { @@ -243,7 +256,16 @@ function ChatBoxContent({
- {hasActiveForm ? ( + {hasActiveApproval ? ( + + ) : hasActiveForm ? ( void; + onApproveAndRemember: () => void; + onReject: () => void; + onDismiss: () => void; +}) { + const fieldId = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + function handleSelect(value: string) { + if (value === 'approve') onApprove(); + else if (value === 'approve-always') onApproveAndRemember(); + else if (value === 'reject') onReject(); + } + + return ( + +
+
+ +
+ +
+ +
+ + {APPROVAL_OPTIONS.map((option, i) => { + const id = `${fieldId}-opt-${i}`; + const isHovered = hoveredIndex === i; + const prevHovered = hoveredIndex === i - 1; + return ( + + {i > 0 && ( +
+ +
+ )} + +
+ ); + })} +
+
+
+ ); +} diff --git a/packages/web/src/app/routes/chat-with-ai/components/tool-call-group.tsx b/packages/web/src/app/routes/chat-with-ai/components/tool-call-group.tsx index e86daef7bd5..227d830b13a 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/tool-call-group.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/tool-call-group.tsx @@ -8,11 +8,9 @@ import { ChainOfThoughtStep, ChainOfThoughtTrigger, } from '@/components/prompt-kit/chain-of-thought'; -import { - extractToolContext, - ToolCallCard, -} from '@/features/chat/components/tool-call-card'; +import { ToolCallCard } from '@/features/chat/components/tool-call-card'; import { ChatUIMessage, DynamicToolPart } from '@/features/chat/lib/chat-types'; +import { chatUtils } from '@/features/chat/lib/chat-utils'; const PENDING_STATES = new Set([ 'input-streaming', @@ -131,7 +129,7 @@ function describeToolParts(parts: DynamicToolPart[]): string { for (const part of parts) { const name = (part.title ?? part.toolName).toLowerCase(); - const ctx = extractToolContext({ + const ctx = chatUtils.extractToolContext({ input: isObject(part.input) ? part.input : undefined, }); if (ctx && !contexts.includes(ctx)) contexts.push(ctx); diff --git a/packages/web/src/features/chat/components/tool-call-card.tsx b/packages/web/src/features/chat/components/tool-call-card.tsx index faf2f8f85bc..5c5f19fe0e5 100644 --- a/packages/web/src/features/chat/components/tool-call-card.tsx +++ b/packages/web/src/features/chat/components/tool-call-card.tsx @@ -10,55 +10,11 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { DynamicToolPart } from '@/features/chat/lib/chat-types'; -import { formatUtils } from '@/lib/format-utils'; +import { chatUtils } from '@/features/chat/lib/chat-utils'; import { cn } from '@/lib/utils'; type ToolStatus = 'running' | 'completed' | 'failed' | 'stopped'; -function humanizePieceName(raw: string): string { - return formatUtils.convertEnumToHumanReadable( - raw.replace(/^@activepieces\/piece-/, '').replace(/-/g, '_'), - ); -} - -export function extractToolContext({ - input, -}: { - input: Record | undefined; -}): string | null { - if (!input) return null; - const parts: string[] = []; - - if (typeof input.pieceName === 'string') { - parts.push(humanizePieceName(input.pieceName)); - } - if (typeof input.actionName === 'string' && input.actionName) { - parts.push(formatUtils.convertEnumToHumanReadable(input.actionName)); - } else if (typeof input.displayName === 'string' && input.displayName) { - parts.push(input.displayName); - } - if (typeof input.triggerName === 'string' && input.triggerName) { - parts.push(formatUtils.convertEnumToHumanReadable(input.triggerName)); - } - if (typeof input.flowId === 'string' && parts.length === 0) { - parts.push(input.flowId.slice(0, 8)); - } - if (typeof input.query === 'string' && parts.length === 0) { - parts.push( - `"${input.query.slice(0, 30)}${input.query.length > 30 ? '…' : ''}"`, - ); - } - if ( - isObject(input.settings) && - typeof input.settings.pieceName === 'string' && - parts.length === 0 - ) { - parts.push(humanizePieceName(input.settings.pieceName)); - } - - return parts.length > 0 ? parts.join(' ') : null; -} - function deriveStatus(part: DynamicToolPart): ToolStatus { if (part.state === 'output-available') return 'completed'; if (part.state === 'output-error') return 'failed'; @@ -78,20 +34,6 @@ function extractOutput(part: DynamicToolPart): string | undefined { return undefined; } -function formatToolLabel({ part }: { part: DynamicToolPart }): string { - const raw = part.title ?? part.toolName; - const mcpMatch = /^mcp__[^_]+__(.+)$/.exec(raw); - const name = mcpMatch ? mcpMatch[1] : raw; - const baseName = formatUtils.convertEnumToHumanReadable( - name.replace(/^ap_/, ''), - ); - - const input = isObject(part.input) ? part.input : undefined; - const context = extractToolContext({ input }); - if (!context) return baseName; - return `${baseName} — ${context}`; -} - function StatusIcon({ status }: { status: ToolStatus }) { switch (status) { case 'running': @@ -122,7 +64,7 @@ export function ToolCallCard({ toolPart }: { toolPart: DynamicToolPart }) { const status = deriveStatus(toolPart); const output = extractOutput(toolPart); const input = isObject(toolPart.input) ? toolPart.input : undefined; - const displayName = formatToolLabel({ part: toolPart }); + const displayName = chatUtils.formatToolLabel({ part: toolPart }); const hasInput = input && Object.keys(input).length > 0; const hasOutput = Boolean(output); const hasContent = hasInput || hasOutput; diff --git a/packages/web/src/features/chat/lib/chat-utils.ts b/packages/web/src/features/chat/lib/chat-utils.ts new file mode 100644 index 00000000000..21c476009f6 --- /dev/null +++ b/packages/web/src/features/chat/lib/chat-utils.ts @@ -0,0 +1,79 @@ +import { isObject } from '@activepieces/shared'; + +import { formatUtils } from '@/lib/format-utils'; + +import { DynamicToolPart } from './chat-types'; + +function humanizePieceName(raw: string): string { + return formatUtils.convertEnumToHumanReadable( + raw.replace(/^@activepieces\/piece-/, '').replace(/-/g, '_'), + ); +} + +function formatToolName({ + part, + includeContext = true, +}: { + part: DynamicToolPart; + includeContext?: boolean; +}): string { + const raw = part.title ?? part.toolName; + const mcpMatch = /^mcp__[^_]+__(.+)$/.exec(raw); + const name = mcpMatch ? mcpMatch[1] : raw; + const baseName = formatUtils.convertEnumToHumanReadable( + name.replace(/^ap_/, ''), + ); + + if (!includeContext) return baseName; + + const input = isObject(part.input) ? part.input : undefined; + const context = extractToolContext({ input }); + if (!context) return baseName; + return `${baseName} — ${context}`; +} + +function extractToolContext({ + input, +}: { + input: Record | undefined; +}): string | null { + if (!input) return null; + const parts: string[] = []; + + if (typeof input.pieceName === 'string') { + parts.push(humanizePieceName(input.pieceName)); + } + if (typeof input.actionName === 'string' && input.actionName) { + parts.push(formatUtils.convertEnumToHumanReadable(input.actionName)); + } else if (typeof input.displayName === 'string' && input.displayName) { + parts.push(input.displayName); + } + if (typeof input.triggerName === 'string' && input.triggerName) { + parts.push(formatUtils.convertEnumToHumanReadable(input.triggerName)); + } + if (typeof input.flowId === 'string' && parts.length === 0) { + parts.push(input.flowId.slice(0, 8)); + } + if (typeof input.query === 'string' && parts.length === 0) { + parts.push( + `"${input.query.slice(0, 30)}${input.query.length > 30 ? '…' : ''}"`, + ); + } + if ( + isObject(input.settings) && + typeof input.settings.pieceName === 'string' && + parts.length === 0 + ) { + parts.push(humanizePieceName(input.settings.pieceName)); + } + + return parts.length > 0 ? parts.join(' ') : null; +} + +export const chatUtils = { + formatToolLabel: ({ part }: { part: DynamicToolPart }) => + formatToolName({ part }), + formatToolActionName: ({ part }: { part: DynamicToolPart }) => + formatToolName({ part, includeContext: false }), + extractToolContext, +}; diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 2bb1456c488..d79445bd91a 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -187,6 +187,11 @@ export function useAgentChat({ selectedProjectIdRef.current = value; _setSelectedProjectId(value); }, []); + const [pendingApprovalRequest, setPendingApprovalRequest] = useState<{ + gateId: string; + toolName: string; + displayName: string; + } | null>(null); const cancelledRef = useRef(false); const messageCountRef = useRef(0); const onTitleUpdateRef = useRef(onTitleUpdate); @@ -234,17 +239,33 @@ export function useAgentChat({ } = useChat({ transport, onData: (dataPart) => { + const data = dataPart.data; if ( dataPart.type === 'data-session-title' && - typeof dataPart.data === 'object' && - dataPart.data !== null && - typeof (dataPart.data as Record)['title'] === 'string' + typeof data === 'object' && + data !== null && + typeof (data as Record)['title'] === 'string' ) { onTitleUpdateRef.current?.( - (dataPart.data as Record)['title'] as string, + (data as Record)['title'] as string, conversationIdRef.current ?? undefined, ); } + if ( + dataPart.type === 'data-approval-request' && + typeof data === 'object' && + data !== null + ) { + const d = data as Record; + if (typeof d.gateId === 'string' && typeof d.toolName === 'string') { + setPendingApprovalRequest({ + gateId: d.gateId, + toolName: d.toolName, + displayName: + typeof d.displayName === 'string' ? d.displayName : d.toolName, + }); + } + } }, onError: () => { setPendingMessages([]); @@ -396,6 +417,7 @@ export function useAgentChat({ cancelledRef.current = false; setLocalError(null); setWasCancelled(false); + setPendingApprovalRequest(null); const fileNames = files?.map((f) => f.name) ?? []; lastSentFileNamesRef.current = fileNames; @@ -523,5 +545,6 @@ export function useAgentChat({ setConversationId, setModelName, setProjectContext, + pendingApprovalRequest, }; } diff --git a/packages/web/src/features/chat/lib/use-tool-approval.ts b/packages/web/src/features/chat/lib/use-tool-approval.ts new file mode 100644 index 00000000000..6c6e04501bb --- /dev/null +++ b/packages/web/src/features/chat/lib/use-tool-approval.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { API_URL } from '@/lib/api'; +import { authenticationSession } from '@/lib/authentication-session'; + +type ApprovalRequest = { + gateId: string; + toolName: string; + displayName: string; +}; + +async function sendApprovalDecision({ + gateId, + approved, +}: { + gateId: string; + approved: boolean; +}): Promise { + const token = authenticationSession.getToken(); + await fetch(`${API_URL}/v1/chat/tool-approvals/${gateId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ approved }), + }); +} + +export function useToolApproval({ + pendingApprovalRequest, +}: { + pendingApprovalRequest: ApprovalRequest | null; +}) { + const autoApproveRef = useRef(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!pendingApprovalRequest) return; + setDismissed(false); + if (autoApproveRef.current) { + void sendApprovalDecision({ + gateId: pendingApprovalRequest.gateId, + approved: true, + }); + } + }, [pendingApprovalRequest]); + + const hasActiveApproval = + pendingApprovalRequest !== null && !autoApproveRef.current && !dismissed; + + const approve = useCallback(() => { + if (!pendingApprovalRequest) return; + setDismissed(true); + void sendApprovalDecision({ + gateId: pendingApprovalRequest.gateId, + approved: true, + }); + }, [pendingApprovalRequest]); + + const approveAndRemember = useCallback(() => { + if (!pendingApprovalRequest) return; + setDismissed(true); + autoApproveRef.current = true; + void sendApprovalDecision({ + gateId: pendingApprovalRequest.gateId, + approved: true, + }); + }, [pendingApprovalRequest]); + + const reject = useCallback(() => { + if (!pendingApprovalRequest) return; + setDismissed(true); + void sendApprovalDecision({ + gateId: pendingApprovalRequest.gateId, + approved: false, + }); + }, [pendingApprovalRequest]); + + const dismiss = useCallback(() => { + if (!pendingApprovalRequest) return; + setDismissed(true); + void sendApprovalDecision({ + gateId: pendingApprovalRequest.gateId, + approved: false, + }); + }, [pendingApprovalRequest]); + + return { + hasActiveApproval, + approvalDisplayName: pendingApprovalRequest?.displayName ?? null, + approve, + approveAndRemember, + reject, + dismiss, + }; +} From b9ee9bf5fe23d69b451ae383d2f4c27c091e4fc3 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 5 May 2026 21:05:45 +0300 Subject: [PATCH 2/4] fix(chat): persist user message before streaming to survive page refresh (#13107) --- packages/server/api/src/app/chat/chat-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/api/src/app/chat/chat-service.ts b/packages/server/api/src/app/chat/chat-service.ts index 81f4abc5b2a..df156e1c167 100644 --- a/packages/server/api/src/app/chat/chat-service.ts +++ b/packages/server/api/src/app/chat/chat-service.ts @@ -163,6 +163,8 @@ export const chatService = (log: FastifyBaseLogger) => ({ const newUserMessage: ModelMessage = { role: 'user' as const, content: userContent } const allMessages = [...previousMessages, newUserMessage] + await conversationRepo().update(conversationId, { messages: allMessages }) + const compactionState = await resolveCompactionState({ conversation, allMessages, From 5cb2268772db7d12b8677c9c3c6cb67051e89248 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 5 May 2026 23:31:58 +0300 Subject: [PATCH 3/4] fix(chat): default to personal project and show Personal Project label (#13109) --- .../app/routes/chat-with-ai/ai-chat-box.tsx | 21 ++++- .../components/chat-project-selector.tsx | 91 +++++++++---------- .../web/src/components/ui/sidebar-shadcn.tsx | 1 + .../components/ap-project-display.tsx | 23 ++++- 4 files changed, 79 insertions(+), 57 deletions(-) diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index 63610458424..f807b59c18f 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -1,8 +1,8 @@ -import { AIProviderName } from '@activepieces/shared'; +import { AIProviderName, ProjectType } from '@activepieces/shared'; import { t } from 'i18next'; import { AlertTriangle, RefreshCw, Square } from 'lucide-react'; import { motion } from 'motion/react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatContainerContent, @@ -98,6 +98,23 @@ function ChatBoxContent({ new Set(), ); + const didAutoSelectProjectRef = useRef(false); + useEffect(() => { + if ( + didAutoSelectProjectRef.current || + selectedProjectId !== null || + initialConversationId + ) + return; + const personalProject = projects.find( + (p) => p.type === ProjectType.PERSONAL, + ); + if (personalProject) { + didAutoSelectProjectRef.current = true; + void setProjectContext(personalProject.id); + } + }, [projects, selectedProjectId, initialConversationId, setProjectContext]); + useEffect(() => { if (initialConversationId) { void setConversationId(initialConversationId); diff --git a/packages/web/src/app/routes/chat-with-ai/components/chat-project-selector.tsx b/packages/web/src/app/routes/chat-with-ai/components/chat-project-selector.tsx index c589f14fc21..0355e1549e3 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/chat-project-selector.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/chat-project-selector.tsx @@ -1,4 +1,4 @@ -import { PROJECT_COLOR_PALETTE, Project } from '@activepieces/shared'; +import { Project, ProjectType } from '@activepieces/shared'; import { t } from 'i18next'; import { Check, ChevronDown, FolderOpen, X } from 'lucide-react'; import { useState } from 'react'; @@ -16,8 +16,15 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { ApProjectDisplay } from '@/features/projects'; import { cn } from '@/lib/utils'; +function projectName(project: Project): string { + return project.type === ProjectType.PERSONAL + ? t('Personal Project') + : project.displayName; +} + export function ChatProjectSelector({ projects, selectedProjectId, @@ -29,10 +36,6 @@ export function ChatProjectSelector({ ? projects.find((p) => p.id === selectedProjectId) : null; - const selectedColor = selectedProject - ? PROJECT_COLOR_PALETTE[selectedProject.icon.color] - : null; - return ( @@ -48,18 +51,13 @@ export function ChatProjectSelector({ > {selectedProject ? ( <> - - {selectedProject.displayName.charAt(0).toUpperCase()} - - - {selectedProject.displayName} - +