diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md index ae47f2f0354c..2a42fc4d763c 100644 --- a/brain/knowledge/engineering/server-module-anatomy.md +++ b/brain/knowledge/engineering/server-module-anatomy.md @@ -148,3 +148,4 @@ Verify with `npm run lint-dev` and `npm run test-api`. - **A new `AppSystemProp` needs three edits, not one.** Add the enum entry in `system-props.ts`, a default in `systemPropDefaultValues` (`system.ts`), *and* a validator in `systemPropValidators` (`system-validator.ts`). Miss the validator and `validateEnvPropsOnStartup` throws `systemPropValidators[prop] is not a function` at boot — every API test fails on setup, not just the new one. Document the var in `docs/install/reference/environment-variables.mdx` too. - **`permission: undefined` on `securityAccess.project(...)` silently allows any project member.** The argument is required in practice even though the type tolerates omitting it. - **Every query filters by `projectId` or `platformId`.** For connections with multi-project access, use `ArrayContains([projectId])` on the `projectIds` array column. +- **A mutation test against a `packages/core/*` package proves nothing until you rebuild its dist.** The api package resolves `@activepieces/core-utils` and friends through `node_modules` to `dist/`, not through the tsconfig path to `src/`, so breaking the source and re-running an api test reports a pass while the test is still executing the old build. Measured: removing the connection-template unwrap from `core-utils/src` left `agent-tool-pinning.test.ts` fully green, and the same mutation failed it once `turbo run build --filter=@activepieces/core-utils` had run. Web tests do not share the trap — vitest aliases the core packages to `src` in `packages/web/vitest.config.ts` — so a web suite catching a core mutation while the api suite ignores it is the signature of a stale dist rather than of missing coverage. Same cause as the phantom "has no exported member" errors after merging a branch that adds a core export. diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index df9638d0b349..47056a5e1b87 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-utils", - "version": "0.6.0", + "version": "0.6.1", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 4e53485c3ad7..b81e8dc83916 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/utils' export * from './lib/object-utils' export * from './lib/assertions' +export * from './lib/connection-template' export * from './lib/try-catch' export * from './lib/id-generator' export * from './lib/base-model' diff --git a/packages/core/utils/src/lib/connection-template.ts b/packages/core/utils/src/lib/connection-template.ts new file mode 100644 index 000000000000..0e21d147806e --- /dev/null +++ b/packages/core/utils/src/lib/connection-template.ts @@ -0,0 +1,10 @@ +const CONNECTION_TEMPLATE = /^\{\{connections\['([^']+)'\]\}\}$/ + +function unwrapExternalId(auth: unknown): string | null { + if (typeof auth !== 'string' || auth.length === 0) { + return null + } + return auth.match(CONNECTION_TEMPLATE)?.[1] ?? auth +} + +export const connectionTemplate = { unwrapExternalId } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index b92301030e34..fb23b95d1c78 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' +import { ActivepiecesError, apId, connectionTemplate, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' import { AgentConversation, AgentConversationStatus, AgentRunSource, AgentToolType, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' @@ -22,7 +22,6 @@ import { findConnectionsForPiece } from './tools/agent-tools' const CHAT_PRINCIPALS = [PrincipalType.USER] as const // Tools configured before 0.87 stored the pin as a template rather than the bare id. -const CONNECTION_TEMPLATE = /^\{\{connections\['([^']+)'\]\}\}$/ export const agentConversationController: FastifyPluginAsyncZod = async (app) => { @@ -348,7 +347,8 @@ async function pinnedAccounts({ conversation, pieceName, platformId, userId, log return [] } const auth = tool.pieceMetadata.predefinedInput?.auth - return isNil(auth) ? [] : [auth.match(CONNECTION_TEMPLATE)?.[1] ?? auth] + const externalId = connectionTemplate.unwrapExternalId(auth) + return isNil(externalId) ? [] : [externalId] }) return { externalIds, projectId: agent.projectId } } diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index 59981152c043..5828dbc768c8 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, ErrorCode, isNil, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' +import { ActivepiecesError, ErrorCode, isNil, Permission, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' import { AgentConfigResponse, AgentConversation, AgentConversationStatus, AgentRunSource, agentToolClassification, ExecuteAgentToolRequest, ExecuteAgentToolResponse, ExecuteFlowToolRequest, ExecuteFlowToolResponse, ExecuteKnowledgeBaseToolRequest, ExecuteKnowledgeBaseToolResponse, ExecutePieceToolRequest, ExecutePieceToolResponse, FileCompression, FileType, FlowActionType, flowStructureUtil, GetAgentConfigRequest, GetEnabledAiToolsResponse, HeartbeatAgentConversationRequest, PersistedAgentMessage, PersistedAgentPartType, PersistedAgentRole, ResumeFlowStepRequest, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, SendAgentEmailRequest, SendAgentEmailResponse, UpdateAgentProgressRequest, UpdateFlowStepProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared' import { embed, ModelMessage } from 'ai' @@ -14,7 +14,9 @@ import { rejectedPromiseHandler } from '../../helper/promise-handler' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' import { knowledgeBaseService } from '../../knowledge-base/knowledge-base.service' +import { resolvePermissionChecker } from '../../mcp/mcp-permissions' import { runFlowAsTool } from '../../mcp/mcp-server-builder' +import { mcpUtils } from '../../mcp/tools/mcp-utils' import { platformService } from '../../platform/platform.service' import { userService } from '../../user/user-service' import { resumeService } from '../../waitpoints/resume-service' @@ -24,6 +26,8 @@ import { agentApprovalGate } from './agent-approval-gate' import { agentCompaction } from './agent-compaction' import { buildAttachmentNote, buildUserContentWithFiles, persistAgentAttachments } from './agent-file-utils' import { agentHelpers } from './agent-helpers' +import { agentService } from './agent-service' +import { agentToolPinning } from './agent-tool-pinning' import { chatAnalyticsTelemetry } from './chat-analytics-sync' import { chatUsageTracker } from './chat-usage-tracker' import { agentMcp } from './mcp/agent-mcp' @@ -607,6 +611,14 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ label: typeof label === 'string' ? label : connectionExternalId, projectId: typeof projectId === 'string' ? projectId : '', }) + await pinConnectionToAgent({ + conversationId: input.conversationId, + pieceName, + externalId: connectionExternalId, + platformId: input.platformId, + userId: input.userId, + log, + }) } return { result: { success: true } } } @@ -815,6 +827,63 @@ async function confinedProjectFor({ conversationId }: { conversationId?: string return conversation.projectId } +async function pinConnectionToAgent({ conversationId, pieceName, externalId, platformId, userId, log }: { + conversationId: string + pieceName: string + externalId: string + platformId: string + userId: string + log: FastifyBaseLogger +}): Promise { + const conversation = await agentHelpers.getConversationOrThrow({ id: conversationId, platformId, userId }) + // Both surfaces that configure a saved agent get the picker, and the builder is where it fires + // most, so pinning has to cover both. Everything else keeps the account for its own run. + const configuresAnAgent = conversation.source === AgentRunSource.AGENT || conversation.source === AgentRunSource.AGENT_BUILDER + if (!configuresAnAgent || isNil(conversation.agentId)) { + return + } + const agent = await agentService(log).getOneOrThrowByPlatform({ id: conversation.agentId, platformId, userId }) + const refuse = (reason: string): void => log.warn({ + conversation: { id: conversationId }, + connection: { id: externalId }, + piece: { name: pieceName }, + agent: { id: agent.id }, + }, `[agentRpc#pinConnectionToAgent] ${reason}`) + + // getOneOrThrowByPlatform resolves through READ_AGENT, which is enough to talk to a shared + // agent and not enough to change what it runs on. Pinning is a write to the saved agent, so it + // asks for the same permission ap_add_agent_tool does. + const checker = await resolvePermissionChecker({ userId, projectId: agent.projectId, log }) + if (!isNil(checker.check(Permission.WRITE_AGENT, '__store_selected_connection'))) { + refuse('Caller cannot write this agent, so the account was used for this run only') + return + } + // externalId arrives from the approval payload, and it is neither validated nor unique across + // projects. Writing it unchecked would let a run bind an agent to another project's connection, + // or hand one app's credential to a different app's action. + const connection = await appConnectionService(log).getOneWithoutValue({ projectId: agent.projectId, platformId, externalId }) + if (isNil(connection)) { + refuse('No such connection in the agent project, so nothing was pinned') + return + } + if (mcpUtils.normalizePieceName(connection.pieceName) !== mcpUtils.normalizePieceName(pieceName)) { + refuse(`Connection is for ${connection.pieceName}, not ${pieceName}, so nothing was pinned`) + return + } + const pinned = await agentService(log).editDraftTools({ + id: agent.id, + projectId: agent.projectId, + userId, + edit: (tools) => agentToolPinning.pinConnection({ tools, pieceName, externalId }), + }) + log.info({ + conversation: { id: conversationId }, + connection: { id: externalId }, + piece: { name: pieceName }, + agent: { id: agent.id }, + }, isNil(pinned) ? '[agentRpc#pinConnectionToAgent] No agent tool to pin' : '[agentRpc#pinConnectionToAgent] Pinned the account to the agent draft') +} + function byteLengthOf(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') diff --git a/packages/server/api/src/app/ee/agent/agent-tool-pinning.ts b/packages/server/api/src/app/ee/agent/agent-tool-pinning.ts new file mode 100644 index 000000000000..78493ac61a6e --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-tool-pinning.ts @@ -0,0 +1,38 @@ +import { AgentToolType } from '@activepieces/core-piece-types' +import { connectionTemplate } from '@activepieces/core-utils' +import { AgentConfig } from '@activepieces/shared' +import { mcpUtils } from '../../mcp/tools/mcp-utils' + +function pinConnection({ tools, pieceName, externalId }: { + tools: AgentConfig['tools'] + pieceName: string + externalId: string +}): AgentConfig['tools'] | null { + const normalized = mcpUtils.normalizePieceName(pieceName) ?? pieceName + let changed = false + const pinned = tools.map((tool) => { + if (tool.type !== AgentToolType.PIECE) { + return tool + } + if (mcpUtils.normalizePieceName(tool.pieceMetadata.pieceName) !== normalized) { + return tool + } + if (connectionTemplate.unwrapExternalId(tool.pieceMetadata.predefinedInput?.auth) === externalId) { + return tool + } + changed = true + return { + ...tool, + pieceMetadata: { + ...tool.pieceMetadata, + predefinedInput: { + ...(tool.pieceMetadata.predefinedInput ?? { fields: {} }), + auth: externalId, + }, + }, + } + }) + return changed ? pinned : null +} + +export const agentToolPinning = { pinConnection } diff --git a/packages/server/api/src/app/ee/agent/tools/agent-tools.ts b/packages/server/api/src/app/ee/agent/tools/agent-tools.ts index cfdd09eaeeb0..750d90ccdb78 100644 --- a/packages/server/api/src/app/ee/agent/tools/agent-tools.ts +++ b/packages/server/api/src/app/ee/agent/tools/agent-tools.ts @@ -23,6 +23,7 @@ import { agentPrompt } from '../prompt/agent-prompt' const AGENT_LIST_LIMIT = 50 const CROSS_PROJECT_CONNECTION_LIMIT = 100 +const ACCOUNT_CHOICE_LIMIT = 10 const OAUTH_TYPES: ReadonlySet = new Set([ AppConnectionType.OAUTH2, AppConnectionType.CLOUD_OAUTH2, @@ -274,6 +275,37 @@ async function updateAgentFromChat({ toolInput, agent, projectId, userId, log }: return afterDraftChange({ agent: updated, publish, projectId, userId, log }) } +async function resolveConnectionToPin({ piece, pieceName, projectId, platformId, log }: { + piece: { displayName: string } + pieceName: string + projectId: string + platformId: string + log: FastifyBaseLogger +}): Promise<{ externalId: string } | { error: string, accounts?: { label: string, externalId: string }[] }> { + const { data } = await appConnectionService(log).list({ + projectId, + platformId, + pieceName, + displayName: undefined, + status: undefined, + cursorRequest: null, + scope: undefined, + externalIds: undefined, + limit: ACCOUNT_CHOICE_LIMIT, + }) + if (data.length === 0) { + return { error: `No ${piece.displayName} account is connected in this project. Call ap_show_connection_picker for ${piece.displayName} so they can connect one here, then call ap_add_agent_tool again with the connectionExternalId it gives you. Do not add the tool without an account: the agent would have to ask which account on every single run.` } + } + if (data.length === 1) { + return { externalId: data[0].externalId } + } + // The ids go in a field rather than the sentence, so the model does not read them out loud. + return { + error: `This project has ${data.length} ${piece.displayName} accounts. Call ap_show_connection_picker for ${piece.displayName} and let the person choose, then call ap_add_agent_tool again with connectionExternalId set to their pick. Do not choose for them.`, + accounts: data.map((connection) => ({ label: connection.displayName, externalId: connection.externalId })), + } +} + async function addAgentToolFromChat({ toolInput, agent, projectId, platformId, userId, log }: { toolInput: Record agent: Agent @@ -293,9 +325,9 @@ async function addAgentToolFromChat({ toolInput, agent, projectId, platformId, u if (isNil(piece) || missing.length > 0) { return { error: `${normalizedPiece} has no action called ${missing.join(' or ')}. Look it up with ap_research_pieces before adding it.` } } - const connectionExternalId = nonEmpty(toolInput.connectionExternalId) - if (!isNil(connectionExternalId)) { - const connection = await appConnectionService(log).getOneWithoutValue({ projectId, platformId, externalId: connectionExternalId }) + const requested = nonEmpty(toolInput.connectionExternalId) + if (!isNil(requested)) { + const connection = await appConnectionService(log).getOneWithoutValue({ projectId, platformId, externalId: requested }) if (isNil(connection)) { return { error: 'No connection with that externalId in this project. Call ap_list_connections and pass one of those.' } } @@ -303,6 +335,15 @@ async function addAgentToolFromChat({ toolInput, agent, projectId, platformId, u return { error: `That connection is for ${connection.pieceName}, not ${normalizedPiece}. Pass a connection for the same app.` } } } + // The caller already holds the piece, so whether it needs an account is known here without + // asking the database again. + const resolved = !isNil(requested) || isNil(piece.auth) + ? { externalId: requested } + : await resolveConnectionToPin({ piece, pieceName: normalizedPiece, projectId, platformId, log }) + if ('error' in resolved) { + return resolved + } + const connectionExternalId = resolved.externalId const added: AgentTool[] = actionNames.map((actionName) => ({ type: AgentToolType.PIECE, toolName: mcpToolNameUtils.createPieceToolName(normalizedPiece, actionName), diff --git a/packages/server/api/test/integration/ee/agent/agent-pin-authorization.test.ts b/packages/server/api/test/integration/ee/agent/agent-pin-authorization.test.ts new file mode 100644 index 000000000000..13604b3625bf --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-pin-authorization.test.ts @@ -0,0 +1,164 @@ +import { AgentIcon, AgentRunSource, ColorName, DefaultProjectRole, PackageType, PieceType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentRpcHandlers } from '../../../../src/app/ee/agent/agent-rpc-handlers' +import { encryptUtils } from '../../../../src/app/helper/encryption' +import { db } from '../../../helpers/db' +import { createMockConnection, createMockPieceMetadata } from '../../../helpers/mocks' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const MAILER = '@activepieces/piece-test-pinmail' +const OTHER = '@activepieces/piece-test-pinchat' + +const action = (name: string) => ({ name, displayName: name, description: name, requireAuth: true, props: {} }) + +beforeAll(async () => { + process.env.AP_AGENTS_ENABLED = 'true' + app = await setupTestEnvironment() + for (const name of [MAILER, OTHER]) { + await db.save('piece_metadata', createMockPieceMetadata({ + name, + displayName: name, + version: '1.0.0', + pieceType: PieceType.OFFICIAL, + packageType: PackageType.REGISTRY, + platformId: undefined, + auth: { type: 'SECRET_TEXT', displayName: 'Key', required: true }, + actions: { send: action('send') }, + })) + } +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) +} + +async function saveConnection({ ctx, pieceName }: { ctx: TestContext, pieceName: string }) { + const connection = createMockConnection({ platformId: ctx.platform.id, projectIds: [ctx.project.id], pieceName, displayName: pieceName }, ctx.user.id) + await db.save('app_connection', { ...connection, value: await encryptUtils.encryptObject(connection.value) }) + return connection +} + +async function agentWithUnpinnedTool(ctx: TestContext) { + const response = await ctx.post('/v1/agents', { + projectId: ctx.project.id, + displayName: 'Pinner', + description: null, + icon: AgentIcon.SPARKLES, + color: ColorName.PURPLE, + draft: { + instructions: 'Handle mail.', + provider: null, + modelName: null, + maxSteps: 5, + structuredOutput: [], + tools: [{ + type: 'PIECE', + toolName: 'pinmail-send', + pieceMetadata: { pieceName: MAILER, pieceVersion: '1.0.0', actionName: 'send' }, + }], + }, + }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +async function conversationFor({ ctx, agentId }: { ctx: TestContext, agentId: string }): Promise { + const response = await ctx.post('/v1/agents/conversations', { agentId }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json().id +} + +async function selectConnection({ ctx, conversationId, pieceName, externalId }: { ctx: TestContext, conversationId: string, pieceName: string, externalId: string }) { + await agentRpcHandlers(app.log).executeAgentTool({ + toolName: '__store_selected_connection', + toolInput: { pieceName, connectionExternalId: externalId, label: 'chosen', projectId: ctx.project.id }, + source: AgentRunSource.AGENT, + conversationId, + platformId: ctx.platform.id, + userId: ctx.user.id, + }) +} + +async function pinnedAuthOn(id: string): Promise<(string | undefined)[]> { + const row = await db.findOneByOrFail<{ draft: { tools: Array<{ pieceMetadata?: { predefinedInput?: { auth?: string } } }> } }>('agent', { id }) + return row.draft.tools.map((tool) => tool.pieceMetadata?.predefinedInput?.auth) +} + +describe('pinning an account chosen mid-conversation', () => { + it('writes the account onto the agent, so the next conversation does not ask again', async () => { + const ctx = await context() + const agent = await agentWithUnpinnedTool(ctx) + const connection = await saveConnection({ ctx, pieceName: MAILER }) + const conversationId = await conversationFor({ ctx, agentId: agent.id }) + + await selectConnection({ ctx, conversationId, pieceName: MAILER, externalId: connection.externalId }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([connection.externalId]) + }) + + it('refuses a connection for a different app, which would hand one provider its rival credential', async () => { + const ctx = await context() + const agent = await agentWithUnpinnedTool(ctx) + const wrongApp = await saveConnection({ ctx, pieceName: OTHER }) + const conversationId = await conversationFor({ ctx, agentId: agent.id }) + + await selectConnection({ ctx, conversationId, pieceName: MAILER, externalId: wrongApp.externalId }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([undefined]) + }) + + it('refuses an externalId that does not exist in the agent project', async () => { + const ctx = await context() + const agent = await agentWithUnpinnedTool(ctx) + const conversationId = await conversationFor({ ctx, agentId: agent.id }) + + await selectConnection({ ctx, conversationId, pieceName: MAILER, externalId: 'invented-external-id' }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([undefined]) + }) + + it('refuses an externalId that lives in another project, since the id is not unique', async () => { + const ctx = await context() + const other = await context() + const agent = await agentWithUnpinnedTool(ctx) + const theirs = await saveConnection({ ctx: other, pieceName: MAILER }) + const conversationId = await conversationFor({ ctx, agentId: agent.id }) + + await selectConnection({ ctx, conversationId, pieceName: MAILER, externalId: theirs.externalId }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([undefined]) + }) + + it('refuses a viewer, who may talk to a shared agent but not change what it runs on', async () => { + const owner = await context() + const viewer = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.VIEWER }) + const agent = await agentWithUnpinnedTool(owner) + const connection = await saveConnection({ ctx: owner, pieceName: MAILER }) + const conversationId = await conversationFor({ ctx: viewer, agentId: agent.id }) + + await selectConnection({ ctx: viewer, conversationId, pieceName: MAILER, externalId: connection.externalId }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([undefined]) + }) + + it('still lets an editor pin, so the guard is not simply refusing everyone', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await agentWithUnpinnedTool(owner) + const connection = await saveConnection({ ctx: owner, pieceName: MAILER }) + const conversationId = await conversationFor({ ctx: editor, agentId: agent.id }) + + await selectConnection({ ctx: editor, conversationId, pieceName: MAILER, externalId: connection.externalId }) + + expect(await pinnedAuthOn(agent.id)).toStrictEqual([connection.externalId]) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-tool-auto-pin.test.ts b/packages/server/api/test/integration/ee/agent/agent-tool-auto-pin.test.ts new file mode 100644 index 000000000000..514fe78c412c --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-tool-auto-pin.test.ts @@ -0,0 +1,151 @@ +import { PackageType, PieceType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { executeCrossProjectTool } from '../../../../src/app/ee/agent/tools/agent-tools' +import { encryptUtils } from '../../../../src/app/helper/encryption' +import { db } from '../../../helpers/db' +import { createMockConnection, createMockPieceMetadata } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const AUTH_PIECE = '@activepieces/piece-test-mailer' +const OPEN_PIECE = '@activepieces/piece-test-clock' + +const action = (name: string) => ({ name, displayName: name, description: name, requireAuth: true, props: {} }) + +beforeAll(async () => { + process.env.AP_AGENTS_ENABLED = 'true' + app = await setupTestEnvironment() + await db.save('piece_metadata', createMockPieceMetadata({ + name: AUTH_PIECE, + displayName: 'Test Mailer', + version: '1.0.0', + pieceType: PieceType.OFFICIAL, + packageType: PackageType.REGISTRY, + platformId: undefined, + auth: { type: 'SECRET_TEXT', displayName: 'Key', required: true }, + actions: { send_mail: action('send_mail'), read_mail: action('read_mail') }, + })) + await db.save('piece_metadata', createMockPieceMetadata({ + name: OPEN_PIECE, + displayName: 'Test Clock', + version: '1.0.0', + pieceType: PieceType.OFFICIAL, + packageType: PackageType.REGISTRY, + platformId: undefined, + actions: { now: { name: 'now', displayName: 'Now', description: 'Now', requireAuth: false, props: {} } }, + })) +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) +} + +async function startConversation(ctx: TestContext): Promise { + const response = await ctx.post('/v1/agents/conversations', {}) + expect(response.statusCode).toBe(StatusCodes.CREATED) + const conversationId = response.json().id + await db.update('agent_conversation', conversationId, { projectId: ctx.project.id }) + return conversationId +} + +async function runTool(ctx: TestContext, conversationId: string, toolName: string, toolInput: Record = {}) { + return executeCrossProjectTool({ toolName, toolInput, platformId: ctx.platform.id, userId: ctx.user.id, conversationId, log: app.log }) +} + +async function newAgent(ctx: TestContext, conversationId: string): Promise { + const created = await runTool(ctx, conversationId, 'ap_create_agent', { displayName: 'Mailer', instructions: 'Handle mail.' }) as { agentId: string } + return created.agentId +} + +async function pinnedAuthOn(id: string): Promise<(string | undefined)[]> { + const row = await db.findOneByOrFail<{ draft: { tools: Array<{ pieceMetadata?: { predefinedInput?: { auth?: string } } }> } }>('agent', { id }) + return row.draft.tools.map((tool) => tool.pieceMetadata?.predefinedInput?.auth) +} + +async function saveConnection(ctx: TestContext, displayName: string) { + const connection = createMockConnection({ projectIds: [ctx.project.id], platformId: ctx.platform.id, pieceName: AUTH_PIECE, displayName }, ctx.user.id) + await db.save('app_connection', { ...connection, value: await encryptUtils.encryptObject(connection.value) }) + return connection +} + +describe('adding a tool that needs an account', () => { + it('pins the only account in the project, so the agent never has to ask', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + const connection = await saveConnection(ctx, 'Work mail') + + await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: AUTH_PIECE, actionNames: ['send_mail'] }) + + expect(await pinnedAuthOn(agentId)).toStrictEqual([connection.externalId]) + }) + + it('pins the same account onto every action added at once', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + const connection = await saveConnection(ctx, 'Work mail') + + await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: AUTH_PIECE, actionNames: ['send_mail', 'read_mail'] }) + + expect(await pinnedAuthOn(agentId)).toStrictEqual([connection.externalId, connection.externalId]) + }) + + it('points at the connection card when the project has no account, instead of asking forever', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + + const result = await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: AUTH_PIECE, actionNames: ['send_mail'] }) + + expect(result).toEqual({ error: expect.stringContaining('No Test Mailer account is connected') }) + expect(await pinnedAuthOn(agentId)).toStrictEqual([]) + }) + + it('points at the picker and keeps the ids out of the sentence when there are several', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + const first = await saveConnection(ctx, 'Work mail') + const second = await saveConnection(ctx, 'Personal mail') + + const result = await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: AUTH_PIECE, actionNames: ['send_mail'] }) + + expect(result).toEqual(expect.objectContaining({ error: expect.stringContaining('2 Test Mailer accounts') })) + // The ids belong in a field, not in the sentence the model reads out. + expect((result as { error: string }).error).not.toContain(first.externalId) + expect(JSON.stringify((result as { accounts: unknown }).accounts)).toContain(first.externalId) + expect(JSON.stringify((result as { accounts: unknown }).accounts)).toContain(second.externalId) + expect(await pinnedAuthOn(agentId)).toStrictEqual([]) + }) + + it('honours an explicit account even when the project has only one, so the caller stays in charge', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + await saveConnection(ctx, 'Work mail') + const chosen = await saveConnection(ctx, 'Personal mail') + + await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: AUTH_PIECE, actionNames: ['send_mail'], connectionExternalId: chosen.externalId }) + + expect(await pinnedAuthOn(agentId)).toStrictEqual([chosen.externalId]) + }) + + it('adds a tool that needs no account with nothing pinned, which is correct rather than missing', async () => { + const ctx = await context() + const conversationId = await startConversation(ctx) + const agentId = await newAgent(ctx, conversationId) + + await runTool(ctx, conversationId, 'ap_add_agent_tool', { agentId, pieceName: OPEN_PIECE, actionNames: ['now'] }) + + expect(await pinnedAuthOn(agentId)).toStrictEqual([undefined]) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-tool-pinning.test.ts b/packages/server/api/test/integration/ee/agent/agent-tool-pinning.test.ts new file mode 100644 index 000000000000..0de8b7339066 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-tool-pinning.test.ts @@ -0,0 +1,97 @@ +import { AgentToolType } from '@activepieces/core-piece-types' +import { describe, expect, it } from 'vitest' +import { agentToolPinning } from '../../../../src/app/ee/agent/agent-tool-pinning' + +const pieceTool = ({ pieceName, actionName, auth }: { pieceName: string, actionName: string, auth?: string }) => ({ + type: AgentToolType.PIECE as const, + toolName: `${pieceName}-${actionName}`, + pieceMetadata: { + pieceName, + pieceVersion: '1.0.0', + actionName, + ...(auth === undefined ? {} : { predefinedInput: { auth, fields: {} } }), + }, +}) + +const gmail = (auth?: string) => pieceTool({ pieceName: '@activepieces/piece-gmail', actionName: 'gmail_search_mail', ...(auth === undefined ? {} : { auth }) }) + +describe('agentToolPinning.pinConnection', () => { + it('pins an account onto a tool that had none, which is the whole loop this closes', () => { + const pinned = agentToolPinning.pinConnection({ tools: [gmail()], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' }) + + expect(pinned?.[0]).toMatchObject({ pieceMetadata: { predefinedInput: { auth: 'conn_1' } } }) + }) + + it('replaces an account that no longer works, so a repair sticks', () => { + const pinned = agentToolPinning.pinConnection({ tools: [gmail('conn_dead')], pieceName: '@activepieces/piece-gmail', externalId: 'conn_new' }) + + expect(pinned?.[0]).toMatchObject({ pieceMetadata: { predefinedInput: { auth: 'conn_new' } } }) + }) + + it('reports no change when the tool already uses that account, so no needless write happens', () => { + expect(agentToolPinning.pinConnection({ tools: [gmail('conn_1')], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' })).toBeNull() + }) + + it('recognises the older template form, so a legacy pin is not treated as a different account', () => { + expect(agentToolPinning.pinConnection({ tools: [gmail("{{connections['conn_1']}}")], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' })).toBeNull() + }) + + it('replaces a legacy template pin when the account really did change', () => { + const pinned = agentToolPinning.pinConnection({ tools: [gmail("{{connections['conn_old']}}")], pieceName: '@activepieces/piece-gmail', externalId: 'conn_new' }) + + expect(pinned?.[0]).toMatchObject({ pieceMetadata: { predefinedInput: { auth: 'conn_new' } } }) + }) + + it('leaves other apps alone, so repairing Gmail cannot touch Slack', () => { + const slack = pieceTool({ pieceName: '@activepieces/piece-slack', actionName: 'send_channel_message', auth: 'conn_slack' }) + + const pinned = agentToolPinning.pinConnection({ tools: [gmail(), slack], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' }) + + expect(pinned?.[1]).toStrictEqual(slack) + }) + + it('pins every action of the same app, since they share one account', () => { + const read = gmail() + const send = pieceTool({ pieceName: '@activepieces/piece-gmail', actionName: 'send_email' }) + + const pinned = agentToolPinning.pinConnection({ tools: [read, send], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' }) + + expect(pinned?.map((tool) => tool.type === AgentToolType.PIECE ? tool.pieceMetadata.predefinedInput?.auth : null)).toStrictEqual(['conn_1', 'conn_1']) + }) + + it('matches on the normalized piece name, so a short name still finds the tool', () => { + const pinned = agentToolPinning.pinConnection({ tools: [gmail()], pieceName: 'gmail', externalId: 'conn_1' }) + + expect(pinned?.[0]).toMatchObject({ pieceMetadata: { predefinedInput: { auth: 'conn_1' } } }) + }) + + it('reports no change when the agent has no tool for that app at all', () => { + const slack = pieceTool({ pieceName: '@activepieces/piece-slack', actionName: 'send_channel_message' }) + + expect(agentToolPinning.pinConnection({ tools: [slack], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' })).toBeNull() + }) + + it('reports no change for an agent with no tools', () => { + expect(agentToolPinning.pinConnection({ tools: [], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' })).toBeNull() + }) + + it('keeps other predefined fields, so pinning does not wipe a configured input', () => { + const withFields = { + ...gmail(), + pieceMetadata: { ...gmail().pieceMetadata, predefinedInput: { fields: { label: 'INBOX' } } }, + } + + const pinned = agentToolPinning.pinConnection({ tools: [withFields], pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' }) + + expect(pinned?.[0]).toMatchObject({ pieceMetadata: { predefinedInput: { auth: 'conn_1', fields: { label: 'INBOX' } } } }) + }) + + it('does not mutate the tools it was given, so a failed save cannot leave a half-edited draft', () => { + const tools = [gmail()] + const snapshot = JSON.stringify(tools) + + agentToolPinning.pinConnection({ tools, pieceName: '@activepieces/piece-gmail', externalId: 'conn_1' }) + + expect(JSON.stringify(tools)).toBe(snapshot) + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 5633e48758d6..ac8132d67a45 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1,4 +1,7 @@ { + "Connect an account": "Connect an account", + "Different account per action": "Different account per action", + "Account was deleted": "Account was deleted", "Write instructions before testing": "Write instructions before testing", "Try it before it goes live": "Try it before it goes live", "Needs a model to run": "Needs a model to run", diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index f118e0178dc4..21994a38c800 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -678,6 +678,7 @@ const AgentEditScreen = ({ blockedReason={blockedFromTesting} conversationId={testConversationId} onConversationCreated={setTestConversationId} + onEdited={onEdited} /> ) : ( @@ -751,11 +752,13 @@ const TestPane = ({ blockedReason, conversationId, onConversationCreated, + onEdited, }: { agent: Agent; blockedReason: string | null; conversationId: string | null; onConversationCreated: (id: string) => void; + onEdited: () => void; }) => !isNil(blockedReason) ? (
@@ -768,6 +771,7 @@ const TestPane = ({ agentId={agent.id} conversationId={conversationId} onConversationCreated={onConversationCreated} + onTurnEnd={onEdited} placeholder={t('Try {name}...', { name: agent.displayName })} footerNote={buildCapabilityNote(agent)} emptyState={} diff --git a/packages/web/src/features/agents/agent-tools/components/piece-tool.tsx b/packages/web/src/features/agents/agent-tools/components/piece-tool.tsx index 875885d1b069..57a6bcc918c3 100644 --- a/packages/web/src/features/agents/agent-tools/components/piece-tool.tsx +++ b/packages/web/src/features/agents/agent-tools/components/piece-tool.tsx @@ -1,3 +1,4 @@ +import { isNil } from '@activepieces/core-utils'; import { AgentPieceTool, mcpToolNameUtils } from '@activepieces/shared'; import { t } from 'i18next'; import { Plus, Puzzle, X } from 'lucide-react'; @@ -15,11 +16,16 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { appConnectionsQueries } from '@/features/connections/hooks/app-connections-hooks'; import { stepsHooks } from '@/features/pieces/hooks/steps-hooks'; import { PieceStepMetadataWithSuggestions } from '@/features/pieces/types'; +import { authenticationSession } from '@/lib/authentication-session'; +import { agentToolAccount } from '../lib/agent-tool-account'; import { usePieceToolsDialogStore } from '../stores/pieces-tools'; +const CONNECTION_PAGE_SIZE = 1000; + type AgentPieceToolProps = { disabled?: boolean; tools: AgentPieceTool[]; @@ -49,6 +55,25 @@ export const AgentPieceToolComponent = ({ (p) => p.pieceName === tools[0].pieceMetadata.pieceName, ); + // One project-wide query keyed only on the project, so every tool row on the screen shares a + // single cache entry with the rest of the app instead of each fetching the same list again. + const projectId = authenticationSession.getProjectId()!; + const { + data: connections, + isSuccess, + isFetching, + } = appConnectionsQueries.useAppConnections({ + request: { projectId, limit: CONNECTION_PAGE_SIZE }, + extraKeys: [projectId], + enabled: !isNil(pieceMetadata?.auth), + }); + const connectionsComplete = agentToolAccount.listIsComplete({ + isSuccess, + isFetching, + count: connections?.data.length ?? 0, + pageSize: CONNECTION_PAGE_SIZE, + }); + if (!pieceMetadata) { return (
@@ -62,6 +87,20 @@ export const AgentPieceToolComponent = ({ ); } + const toolsNeedingAccount = tools.filter((tool) => + agentToolAccount.requiresAccount({ + pieceHasAuth: !isNil(pieceMetadata.auth), + actionRequireAuth: pieceMetadata.suggestedActions?.find( + (action) => action.name === tool.pieceMetadata.actionName, + )?.requireAuth, + }), + ); + const accountLabel = agentToolAccount.label({ + tools: toolsNeedingAccount, + connections: connections?.data ?? [], + connectionsComplete, + }); + const handleEditTool = (tool: AgentPieceTool) => { openAddPieceToolDialog({ page: 'action-inputs', tool }); }; @@ -90,6 +129,11 @@ export const AgentPieceToolComponent = ({ {pieceMetadata.displayName}
+ {!isNil(accountLabel) && ( + + {accountLabel} + + )}
diff --git a/packages/web/src/features/agents/agent-tools/lib/agent-tool-account.ts b/packages/web/src/features/agents/agent-tools/lib/agent-tool-account.ts new file mode 100644 index 000000000000..9c77257bdf3e --- /dev/null +++ b/packages/web/src/features/agents/agent-tools/lib/agent-tool-account.ts @@ -0,0 +1,73 @@ +import { connectionTemplate, isNil } from '@activepieces/core-utils'; +import { AgentPieceTool } from '@activepieces/shared'; +import { t } from 'i18next'; + +function pinnedExternalId(tool: AgentPieceTool): string | null { + return connectionTemplate.unwrapExternalId( + tool.pieceMetadata.predefinedInput?.auth, + ); +} + +function requiresAccount({ + pieceHasAuth, + actionRequireAuth, +}: { + pieceHasAuth: boolean; + actionRequireAuth: boolean | undefined; +}): boolean { + // The framework defaults an action to requiring auth, so an action we cannot find is assumed to + // need one rather than quietly labelled as fine. + return pieceHasAuth && actionRequireAuth !== false; +} + +function listIsComplete({ + isSuccess, + isFetching, + count, + pageSize, +}: { + isSuccess: boolean; + isFetching: boolean; + count: number; + pageSize: number; +}): boolean { + // Cached data counts as success while a refetch is still running, and that page can predate a + // connection made a moment ago, so a fetch in flight means we do not yet know the full list. + if (!isSuccess || isFetching) { + return false; + } + // A full page means there may be more we cannot see. + return count < pageSize; +} + +function label({ + tools, + connections, + connectionsComplete, +}: { + tools: AgentPieceTool[]; + connections: { externalId: string; displayName: string }[]; + connectionsComplete: boolean; +}): string | null { + if (tools.length === 0) { + return null; + } + const externalIds = [...new Set(tools.map(pinnedExternalId))]; + if (externalIds.some(isNil)) { + return t('Connect an account'); + } + if (externalIds.length > 1) { + return t('Different account per action'); + } + const pinned = connections.find( + (connection) => connection.externalId === externalIds[0], + ); + if (!isNil(pinned)) { + return pinned.displayName; + } + // Absent from a list we know is partial proves nothing, and a wrong "deleted" is worse than + // saying nothing at all. + return connectionsComplete ? t('Account was deleted') : null; +} + +export const agentToolAccount = { label, listIsComplete, requiresAccount }; diff --git a/packages/web/src/features/agents/agent-tools/piece-tool-dialog/connection-select.tsx b/packages/web/src/features/agents/agent-tools/piece-tool-dialog/connection-select.tsx index a6c8af2a03ed..93413693cf7f 100644 --- a/packages/web/src/features/agents/agent-tools/piece-tool-dialog/connection-select.tsx +++ b/packages/web/src/features/agents/agent-tools/piece-tool-dialog/connection-select.tsx @@ -1,3 +1,4 @@ +import { connectionTemplate } from '@activepieces/core-utils'; import { PieceMetadataModelSummary } from '@activepieces/pieces-framework'; import { t } from 'i18next'; import React, { useState } from 'react'; @@ -17,14 +18,6 @@ type ConnectionDropdownProps = { showError?: boolean; }; -// Older steps stored the connection as a template. It is stored by id now, so both are read. -function unwrapConnection(input?: unknown): string | undefined { - if (typeof input !== 'string') return undefined; - - const match = input.match(/^\{\{connections\['([^']+)'\]\}\}$/); - return match?.[1] ?? input; -} - export const ConnectionDropdown = React.memo( ({ piece, @@ -39,7 +32,6 @@ export const ConnectionDropdown = React.memo( const { data: connections, isLoading: connectionsLoading, - refetch: refetchConnections, isRefetching: isRefetchingConnections, } = appConnectionsQueries.useAppConnections({ request: { @@ -83,7 +75,6 @@ export const ConnectionDropdown = React.memo( setConnectionDialogOpen(open); if (connection) { onChange(connection.externalId); - refetchConnections(); } }} reconnectConnection={null} @@ -92,7 +83,7 @@ export const ConnectionDropdown = React.memo(
( return false; } - if (!selectedAction.requireAuth || isNil(selectedPiece.auth)) { + if ( + !agentToolAccount.requiresAccount({ + pieceHasAuth: !isNil(selectedPiece.auth), + actionRequireAuth: selectedAction.requireAuth, + }) + ) { return true; } diff --git a/packages/web/src/features/connections/hooks/app-connections-hooks.ts b/packages/web/src/features/connections/hooks/app-connections-hooks.ts index 46272df7fae1..832bda8f4894 100644 --- a/packages/web/src/features/connections/hooks/app-connections-hooks.ts +++ b/packages/web/src/features/connections/hooks/app-connections-hooks.ts @@ -82,6 +82,7 @@ export const appConnectionsMutations = { form, setOpen, }: UseUpsertAppConnectionProps) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { setErrorMessage(''); @@ -116,6 +117,10 @@ export const appConnectionsMutations = { return appConnectionsApi.upsert(formValues); }, onSuccess: (connection) => { + // Every cached connection list is now out of date, whichever key it was fetched under. + // Refreshing only the caller's own query left other readers stale enough to describe a + // brand-new account as deleted. + void queryClient.invalidateQueries({ queryKey: ['app-connections'] }); setOpen(false, connection); setErrorMessage(''); }, diff --git a/packages/web/test/app/routes/agents/id/test-pane.test.tsx b/packages/web/test/app/routes/agents/id/test-pane.test.tsx index bb856c708367..eb58866a519d 100644 --- a/packages/web/test/app/routes/agents/id/test-pane.test.tsx +++ b/packages/web/test/app/routes/agents/id/test-pane.test.tsx @@ -33,6 +33,7 @@ describe('TestPane', () => { blockedReason="Pick a model before testing" conversationId={null} onConversationCreated={vi.fn()} + onEdited={vi.fn()} />, ); @@ -47,6 +48,7 @@ describe('TestPane', () => { blockedReason="Write instructions before testing" conversationId={null} onConversationCreated={vi.fn()} + onEdited={vi.fn()} />, ); @@ -61,6 +63,7 @@ describe('TestPane', () => { blockedReason={null} conversationId={null} onConversationCreated={vi.fn()} + onEdited={vi.fn()} />, ); @@ -76,6 +79,7 @@ describe('TestPane', () => { blockedReason={null} conversationId="conv_9" onConversationCreated={vi.fn()} + onEdited={vi.fn()} />, ); diff --git a/packages/web/test/features/agents/agent-tools/lib/agent-tool-account.test.ts b/packages/web/test/features/agents/agent-tools/lib/agent-tool-account.test.ts new file mode 100644 index 000000000000..296c5516489b --- /dev/null +++ b/packages/web/test/features/agents/agent-tools/lib/agent-tool-account.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('i18next', () => ({ t: (key: string) => key })); + +import { agentToolAccount } from '@/features/agents/agent-tools/lib/agent-tool-account'; + +const tool = (auth?: string, actionName = 'send') => + ({ + type: 'PIECE', + toolName: `gmail-${actionName}`, + pieceMetadata: { + pieceName: '@activepieces/piece-gmail', + pieceVersion: '1.0.0', + actionName, + ...(auth === undefined ? {} : { predefinedInput: { auth, fields: {} } }), + }, + }) as never; + +const connections = [{ externalId: 'conn_1', displayName: 'Work mail' }]; +const loaded = { connections, connectionsComplete: true }; + +describe('agentToolAccount.requiresAccount', () => { + it('needs one when the piece has auth and the action does not opt out', () => { + expect( + agentToolAccount.requiresAccount({ + pieceHasAuth: true, + actionRequireAuth: true, + }), + ).toBe(true); + }); + + it('needs none when the piece has no auth at all', () => { + expect( + agentToolAccount.requiresAccount({ + pieceHasAuth: false, + actionRequireAuth: true, + }), + ).toBe(false); + }); + + it('needs none for an action that opts out, so a formatter is not asked for an account', () => { + expect( + agentToolAccount.requiresAccount({ + pieceHasAuth: true, + actionRequireAuth: false, + }), + ).toBe(false); + }); + + it('assumes an unknown action needs one, matching the framework default', () => { + expect( + agentToolAccount.requiresAccount({ + pieceHasAuth: true, + actionRequireAuth: undefined, + }), + ).toBe(true); + }); +}); + +describe('agentToolAccount.label', () => { + it('says nothing when no tool needs an account', () => { + expect(agentToolAccount.label({ tools: [], ...loaded })).toBeNull(); + }); + + it('asks for a connection when a tool has none, worded as the action to take', () => { + expect(agentToolAccount.label({ tools: [tool()], ...loaded })).toBe( + 'Connect an account', + ); + }); + + it('names the pinned account so the row says which one is used', () => { + expect(agentToolAccount.label({ tools: [tool('conn_1')], ...loaded })).toBe( + 'Work mail', + ); + }); + + it('reads the older template pin, so a legacy agent does not look broken', () => { + expect( + agentToolAccount.label({ + tools: [tool("{{connections['conn_1']}}")], + ...loaded, + }), + ).toBe('Work mail'); + }); + + it('stays silent while the connections are still loading, instead of crying deleted', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_1')], + connections: [], + connectionsComplete: false, + }), + ).toBeNull(); + }); + + it('stays silent when the list was truncated, since absence proves nothing there', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_outside_page')], + connections, + connectionsComplete: false, + }), + ).toBeNull(); + }); + + it('still names an account found inside a truncated list', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_1')], + connections, + connectionsComplete: false, + }), + ).toBe('Work mail'); + }); + + it('says the account was deleted once we know it is really gone', () => { + expect( + agentToolAccount.label({ tools: [tool('conn_deleted')], ...loaded }), + ).toBe('Account was deleted'); + }); + + it('asks for a connection when one action of the app still lacks it', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_1'), tool()], + ...loaded, + }), + ).toBe('Connect an account'); + }); + + it('names the consequence when actions disagree, not merely that several exist', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_1'), tool('conn_2', 'read')], + connections: [ + ...connections, + { externalId: 'conn_2', displayName: 'Personal mail' }, + ], + connectionsComplete: true, + }), + ).toBe('Different account per action'); + }); + + it('distinguishes two different dangling pins, which a name-based dedupe merged', () => { + expect( + agentToolAccount.label({ + tools: [tool('gone_a'), tool('gone_b', 'read')], + ...loaded, + }), + ).toBe('Different account per action'); + }); + + it('treats two connections that share a display name as two accounts', () => { + expect( + agentToolAccount.label({ + tools: [tool('conn_1'), tool('conn_2', 'read')], + connections: [ + { externalId: 'conn_1', displayName: 'Mail' }, + { externalId: 'conn_2', displayName: 'Mail' }, + ], + connectionsComplete: true, + }), + ).toBe('Different account per action'); + }); +}); + +describe('agentToolAccount.listIsComplete', () => { + const full = { isSuccess: true, isFetching: false, count: 5, pageSize: 1000 }; + + it('is complete for a settled partial page, which is the normal case', () => { + expect(agentToolAccount.listIsComplete(full)).toBe(true); + }); + + it('is not complete while a refetch is in flight, even though cached data already succeeded', () => { + expect( + agentToolAccount.listIsComplete({ ...full, isFetching: true }), + ).toBe(false); + }); + + it('is not complete before the first success', () => { + expect( + agentToolAccount.listIsComplete({ ...full, isSuccess: false }), + ).toBe(false); + }); + + it('is not complete when the page came back full, since more may exist unseen', () => { + expect( + agentToolAccount.listIsComplete({ ...full, count: 1000 }), + ).toBe(false); + }); + + it('is complete for an empty settled list, so a project with no connections is knowable', () => { + expect(agentToolAccount.listIsComplete({ ...full, count: 0 })).toBe(true); + }); +});