From 40cd20b38b476444a95a2da01eefce2e9122306a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 23 Aug 2026 10:38:53 +0300 Subject: [PATCH 01/10] fix(api-service): stop auto-provisioning Novu Slack demo integration fixes NV-8537 (#12401) Co-authored-by: Cursor Agent --- .../create-novu-integrations.usecase.spec.ts | 73 +++++++++++++++++++ .../create-novu-integrations.usecase.ts | 34 --------- .../e2e/create-organization.e2e.ts | 5 +- 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.spec.ts b/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.spec.ts index ed83654f573..81493c88348 100644 --- a/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.spec.ts +++ b/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.spec.ts @@ -3,6 +3,7 @@ import { IntegrationRepository } from '@novu/dal'; import { AgentRuntimeProviderIdEnum, ChannelTypeEnum, + ChatProviderIdEnum, EnvironmentEnum, EnvironmentTypeEnum, FeatureFlagsKeysEnum, @@ -185,3 +186,75 @@ describe('CreateNovuIntegrations - managed Claude demo integration', () => { expect(managedClaudeCall).to.equal(undefined); }); }); + +describe('CreateNovuIntegrations - Novu Slack demo integration', () => { + let useCase: CreateNovuIntegrations; + let createIntegration: sinon.SinonStubbedInstance; + let integrationRepository: sinon.SinonStubbedInstance; + let setIntegrationAsPrimary: sinon.SinonStubbedInstance; + let featureFlagsService: sinon.SinonStubbedInstance; + let analyticsService: sinon.SinonStubbedInstance; + let previousClientId: string | undefined; + let previousClientSecret: string | undefined; + const validIntegrationId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + previousClientId = process.env.NOVU_SLACK_INTEGRATION_CLIENT_ID; + previousClientSecret = process.env.NOVU_SLACK_INTEGRATION_CLIENT_SECRET; + process.env.NOVU_SLACK_INTEGRATION_CLIENT_ID = 'slack-client-id'; + process.env.NOVU_SLACK_INTEGRATION_CLIENT_SECRET = 'slack-client-secret'; + process.env.NOVU_MANAGED_CLAUDE_API_KEY = ''; + + createIntegration = sinon.createStubInstance(CreateIntegration); + integrationRepository = sinon.createStubInstance(IntegrationRepository); + setIntegrationAsPrimary = sinon.createStubInstance(SetIntegrationAsPrimary); + featureFlagsService = sinon.createStubInstance(FeatureFlagsService); + analyticsService = sinon.createStubInstance(AnalyticsService); + + integrationRepository.count.resolves(0); + featureFlagsService.getFlag.resolves(false); + createIntegration.execute.resolves({ _id: validIntegrationId } as any); + + useCase = new CreateNovuIntegrations( + createIntegration as any, + integrationRepository as any, + setIntegrationAsPrimary as any, + featureFlagsService as any, + analyticsService as any + ); + }); + + afterEach(() => { + if (previousClientId === undefined) { + delete process.env.NOVU_SLACK_INTEGRATION_CLIENT_ID; + } else { + process.env.NOVU_SLACK_INTEGRATION_CLIENT_ID = previousClientId; + } + + if (previousClientSecret === undefined) { + delete process.env.NOVU_SLACK_INTEGRATION_CLIENT_SECRET; + } else { + process.env.NOVU_SLACK_INTEGRATION_CLIENT_SECRET = previousClientSecret; + } + }); + + it('should NOT provision novu-slack even when credentials are set on Development', async () => { + await useCase.execute( + CreateNovuIntegrationsCommand.create({ + environmentId: 'env-id', + organizationId: 'org-id', + userId: 'user-id', + name: EnvironmentEnum.DEVELOPMENT, + environmentType: EnvironmentTypeEnum.DEV, + channels: [ChannelTypeEnum.CHAT], + }) + ); + + const novuSlackCall = createIntegration.execute + .getCalls() + .find((call) => call.args[0].providerId === ChatProviderIdEnum.Novu); + + expect(novuSlackCall).to.equal(undefined); + expect(createIntegration.execute.called).to.equal(false); + }); +}); diff --git a/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.ts b/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.ts index 90ec3e898c4..d5499f2fd4a 100644 --- a/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.ts +++ b/apps/api/src/app/integrations/usecases/create-novu-integrations/create-novu-integrations.usecase.ts @@ -3,7 +3,6 @@ import { AnalyticsService, areNovuEmailCredentialsSet, areNovuManagedClaudeCredentialsSet, - areNovuSlackCredentialsSet, FeatureFlagsService, } from '@novu/application-generic'; import { EnvironmentEntity, IntegrationRepository, OrganizationEntity, UserEntity } from '@novu/dal'; @@ -11,7 +10,6 @@ import { EnvironmentEntity, IntegrationRepository, OrganizationEntity, UserEntit import { AgentRuntimeProviderIdEnum, ChannelTypeEnum, - ChatProviderIdEnum, EmailProviderIdEnum, EnvironmentEnum, EnvironmentTypeEnum, @@ -163,34 +161,6 @@ export class CreateNovuIntegrations { } } - private async createSlackIntegration(command: CreateNovuIntegrationsCommand) { - if (!areNovuSlackCredentialsSet() || command.name !== EnvironmentEnum.DEVELOPMENT) { - return; - } - - const slackIntegrationCount = await this.integrationRepository.count({ - providerId: ChatProviderIdEnum.Novu, - channel: ChannelTypeEnum.CHAT, - _organizationId: command.organizationId, - _environmentId: command.environmentId, - }); - - if (slackIntegrationCount === 0) { - await this.createIntegration.execute( - CreateIntegrationCommand.create({ - name: 'Novu Slack', - providerId: ChatProviderIdEnum.Novu, - channel: ChannelTypeEnum.CHAT, - active: true, - check: false, - userId: command.userId, - environmentId: command.environmentId, - organizationId: command.organizationId, - }) - ); - } - } - async execute(command: CreateNovuIntegrationsCommand): Promise { const integrationPromises: Array> = []; @@ -202,10 +172,6 @@ export class CreateNovuIntegrations { integrationPromises.push(this.createInAppIntegration(command)); } - if (!command.channels || command.channels.includes(ChannelTypeEnum.CHAT)) { - integrationPromises.push(this.createSlackIntegration(command)); - } - integrationPromises.push(this.createManagedClaudeIntegration(command)); await Promise.all(integrationPromises); diff --git a/apps/api/src/app/organization/e2e/create-organization.e2e.ts b/apps/api/src/app/organization/e2e/create-organization.e2e.ts index 188e11c8a4d..a4797d0ea4e 100644 --- a/apps/api/src/app/organization/e2e/create-organization.e2e.ts +++ b/apps/api/src/app/organization/e2e/create-organization.e2e.ts @@ -142,7 +142,7 @@ describe('Create Organization - /organizations (POST) #novu-v0-os', async () => expect(integrations.length).to.eq(6); expect(novuEmailIntegration?.length).to.eq(2); expect(novuSmsIntegration?.length).to.eq(2); - expect(novuChatIntegration?.length).to.eq(1); + expect(novuChatIntegration?.length).to.eq(0); expect(novuInAppIntegration?.length).to.eq(2); expect(novuEmailIntegrationProduction.length).to.eq(1); @@ -204,8 +204,5 @@ describe('Create Organization - /organizations (POST) #novu-v0-os', async () => process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID = oldNovuSmsIntegrationAccountSid; }); - it('when Novu Chat credentials are not set it should not create Novu Chat integration', async () => { - // todo - }); }); }); From 8fb11dfd9cbe3c5ec8be0066de10d664d2591822 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 23 Aug 2026 13:14:58 +0300 Subject: [PATCH 02/10] feat(api-service): Human API and CLI (fixes NV-8589) (#12329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paweł Tymczuk --- apps/api/src/app.module.ts | 2 + apps/api/src/app/agents/agents.module.ts | 16 +- .../channels/agent-config-resolver.service.ts | 5 +- .../runtime/runtime-resolver.service.ts | 8 +- .../agents/e2e/helpers/telegram-api-stub.ts | 10 + .../human-relay/human-action-id.spec.ts | 51 + .../app/agents/human-relay/human-action-id.ts | 67 + .../agents/human-relay/human-card.builder.ts | 194 ++ .../human-interaction-settlement.service.ts | 111 ++ .../agents/human-relay/human-relay.runtime.ts | 363 ++++ .../delete-agent/delete-agent.usecase.ts | 6 +- .../update-agent/update-agent.usecase.ts | 4 + .../channel-endpoints.controller.ts | 3 + .../dtos/create-interaction-request.dto.ts | 73 + .../human/dtos/interaction-response.dto.ts | 63 + .../human/dtos/list-interactions-query.dto.ts | 29 + .../app/human/dtos/setup-human-relay.dto.ts | 35 + .../app/human/e2e/human-interactions.e2e.ts | 527 ++++++ .../human/human-interactions.controller.ts | 147 ++ apps/api/src/app/human/human.module.ts | 42 + .../human/services/human-delivery.service.ts | 225 +++ .../cancel-interaction.command.ts | 8 + .../cancel-interaction.usecase.ts | 46 + .../create-interaction.command.ts | 36 + .../create-interaction.usecase.ts | 164 ++ .../get-interaction.command.ts | 8 + .../get-interaction.usecase.ts | 33 + .../list-interactions.command.ts | 21 + .../list-interactions.usecase.ts | 31 + .../setup-human-relay.command.ts | 16 + .../setup-human-relay.usecase.ts | 100 ++ libs/dal/src/index.ts | 1 + .../repositories/agent/agent.repository.ts | 3 + .../src/repositories/agent/agent.schema.ts | 2 +- .../human-interaction.entity.ts | 72 + .../human-interaction.repository.ts | 164 ++ .../human-interaction.schema.ts | 108 ++ .../repositories/human-interaction/index.ts | 3 + packages/human/.gitignore | 4 + packages/human/README.md | 64 + packages/human/package.json | 53 + packages/human/pnpm-lock.yaml | 1581 +++++++++++++++++ packages/human/pnpm-workspace.yaml | 4 + packages/human/site/.gitignore | 1 + packages/human/site/index.html | 89 + packages/human/site/sapien.html | 899 ++++++++++ packages/human/src/api/client.ts | 113 ++ packages/human/src/api/human.ts | 93 + packages/human/src/api/setup.ts | 255 +++ packages/human/src/commands/channels.ts | 42 + packages/human/src/commands/interact.spec.ts | 54 + packages/human/src/commands/interact.ts | 139 ++ packages/human/src/commands/list.ts | 57 + packages/human/src/commands/setup.ts | 583 ++++++ packages/human/src/commands/skill.ts | 65 + packages/human/src/commands/wait.ts | 18 + packages/human/src/config.spec.ts | 96 + packages/human/src/config.ts | 110 ++ packages/human/src/index.ts | 122 ++ packages/human/src/output.ts | 80 + packages/human/src/poll.ts | 21 + packages/human/src/qr.ts | 38 + .../src/skills/content/human-cli/SKILL.md | 137 ++ .../human/src/skills/install-skills.spec.ts | 66 + packages/human/src/skills/install-skills.ts | 107 ++ packages/human/src/spinner.ts | 50 + packages/human/tsconfig.json | 20 + .../src/dto/agent/managed-runtime.dto.ts | 2 +- .../shared/src/types/human-interaction.ts | 74 + packages/shared/src/types/index.ts | 1 + pnpm-lock.yaml | 90 + 71 files changed, 7919 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/app/agents/human-relay/human-action-id.spec.ts create mode 100644 apps/api/src/app/agents/human-relay/human-action-id.ts create mode 100644 apps/api/src/app/agents/human-relay/human-card.builder.ts create mode 100644 apps/api/src/app/agents/human-relay/human-interaction-settlement.service.ts create mode 100644 apps/api/src/app/agents/human-relay/human-relay.runtime.ts create mode 100644 apps/api/src/app/human/dtos/create-interaction-request.dto.ts create mode 100644 apps/api/src/app/human/dtos/interaction-response.dto.ts create mode 100644 apps/api/src/app/human/dtos/list-interactions-query.dto.ts create mode 100644 apps/api/src/app/human/dtos/setup-human-relay.dto.ts create mode 100644 apps/api/src/app/human/e2e/human-interactions.e2e.ts create mode 100644 apps/api/src/app/human/human-interactions.controller.ts create mode 100644 apps/api/src/app/human/human.module.ts create mode 100644 apps/api/src/app/human/services/human-delivery.service.ts create mode 100644 apps/api/src/app/human/usecases/cancel-interaction/cancel-interaction.command.ts create mode 100644 apps/api/src/app/human/usecases/cancel-interaction/cancel-interaction.usecase.ts create mode 100644 apps/api/src/app/human/usecases/create-interaction/create-interaction.command.ts create mode 100644 apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.ts create mode 100644 apps/api/src/app/human/usecases/get-interaction/get-interaction.command.ts create mode 100644 apps/api/src/app/human/usecases/get-interaction/get-interaction.usecase.ts create mode 100644 apps/api/src/app/human/usecases/list-interactions/list-interactions.command.ts create mode 100644 apps/api/src/app/human/usecases/list-interactions/list-interactions.usecase.ts create mode 100644 apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.command.ts create mode 100644 apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.usecase.ts create mode 100644 libs/dal/src/repositories/human-interaction/human-interaction.entity.ts create mode 100644 libs/dal/src/repositories/human-interaction/human-interaction.repository.ts create mode 100644 libs/dal/src/repositories/human-interaction/human-interaction.schema.ts create mode 100644 libs/dal/src/repositories/human-interaction/index.ts create mode 100644 packages/human/.gitignore create mode 100644 packages/human/README.md create mode 100644 packages/human/package.json create mode 100644 packages/human/pnpm-lock.yaml create mode 100644 packages/human/pnpm-workspace.yaml create mode 100644 packages/human/site/.gitignore create mode 100644 packages/human/site/index.html create mode 100644 packages/human/site/sapien.html create mode 100644 packages/human/src/api/client.ts create mode 100644 packages/human/src/api/human.ts create mode 100644 packages/human/src/api/setup.ts create mode 100644 packages/human/src/commands/channels.ts create mode 100644 packages/human/src/commands/interact.spec.ts create mode 100644 packages/human/src/commands/interact.ts create mode 100644 packages/human/src/commands/list.ts create mode 100644 packages/human/src/commands/setup.ts create mode 100644 packages/human/src/commands/skill.ts create mode 100644 packages/human/src/commands/wait.ts create mode 100644 packages/human/src/config.spec.ts create mode 100644 packages/human/src/config.ts create mode 100644 packages/human/src/index.ts create mode 100644 packages/human/src/output.ts create mode 100644 packages/human/src/poll.ts create mode 100644 packages/human/src/qr.ts create mode 100644 packages/human/src/skills/content/human-cli/SKILL.md create mode 100644 packages/human/src/skills/install-skills.spec.ts create mode 100644 packages/human/src/skills/install-skills.ts create mode 100644 packages/human/src/spinner.ts create mode 100644 packages/human/tsconfig.json create mode 100644 packages/shared/src/types/human-interaction.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a18ef75eecd..f17f319f084 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { SentryModule } from '@sentry/nestjs/setup'; import packageJson from '../package.json'; import { ActivityModule } from './app/activity/activity.module'; import { AgentsModule } from './app/agents/agents.module'; +import { HumanModule } from './app/human/human.module'; import { AnalyticsModule } from './app/analytics/analytics.module'; import { AuthModule } from './app/auth/auth.module'; import { BlueprintModule } from './app/blueprint/blueprint.module'; @@ -136,6 +137,7 @@ const baseModules: Array | Forward OrganizationModule, ActivityModule, AgentsModule, + HumanModule, ConnectModule, NovuContextModule, DomainsModule.forRoot(), diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index 35c39a18667..92605c90d0f 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -18,6 +18,7 @@ import { ConversationActivationRepository, ConversationActivityRepository, ConversationRepository, + HumanInteractionRepository, IntegrationRepository, McpConnectionRepository, MessageRepository, @@ -75,6 +76,8 @@ import { AgentEmailActionsController } from './email/agent-email-actions.control import { AgentEmailSender } from './email/agent-email-sender.service'; import { NovuEmailCleanupService } from './email/novu-email/cleanup-novu-email/cleanup-novu-email.service'; import { NovuEmailProvisioningService } from './email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service'; +import { HumanInteractionSettlementService } from './human-relay/human-interaction-settlement.service'; +import { HumanRelayRuntime } from './human-relay/human-relay.runtime'; import { AgentRuntimeDefinitionService } from './managed-runtime/agent-runtime-definition.service'; import { DemoClaudeQuotaPolicy } from './managed-runtime/demo-claude-quota-policy.service'; import { ManagedRuntime } from './managed-runtime/managed.runtime'; @@ -161,6 +164,9 @@ import { USE_CASES } from './usecases'; BridgeExpireSupersededApprovalsService, BridgeRuntime, ManagedRuntime, + HumanRelayRuntime, + HumanInteractionSettlementService, + HumanInteractionRepository, RuntimeResolver, ManagedAgentProviderFactory, ManagedAgentEventHandler, @@ -202,6 +208,14 @@ import { USE_CASES } from './usecases'; AgentConversationEnabledGuard, AgentChatEnabledGuard, ], - exports: [...USE_CASES, ChatInstanceRegistry, InboundDispatcher, OutboundGateway, ConfirmLinkedAuthCards], + exports: [ + ...USE_CASES, + ChatInstanceRegistry, + InboundDispatcher, + OutboundGateway, + ConfirmLinkedAuthCards, + ConversationActivityLedger, + HumanInteractionSettlementService, + ], }) export class AgentsModule {} diff --git a/apps/api/src/app/agents/channels/agent-config-resolver.service.ts b/apps/api/src/app/agents/channels/agent-config-resolver.service.ts index 4f66e1d2603..b96c621d7a0 100644 --- a/apps/api/src/app/agents/channels/agent-config-resolver.service.ts +++ b/apps/api/src/app/agents/channels/agent-config-resolver.service.ts @@ -316,7 +316,10 @@ export class AgentConfigResolver { integrationIdentifier, integrationId: integration._id, providerId: integration.providerId, - removeNovuBranding: await this.resolveRemoveNovuBranding(organizationId), + // Human-relay messages are utility traffic between a person and their own + // agents — never consumer-facing agent chat — so they always ship unbranded. + removeNovuBranding: + agent.runtime === 'human_relay' ? true : await this.resolveRemoveNovuBranding(organizationId), acknowledgeOnReceived: agent.behavior?.acknowledgeOnReceived !== false, reactionOnResolved: await resolveReaction( agent.behavior?.reactionOnResolved, diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts b/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts index 4c91dfcf673..7a3bc18d0c0 100644 --- a/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import type { AgentEntity } from '@novu/dal'; +import { HumanRelayRuntime } from '../../human-relay/human-relay.runtime'; import { ManagedRuntime } from '../../managed-runtime/managed.runtime'; import type { AgentRuntime } from './agent-runtime.port'; import { BridgeRuntime } from './bridge.runtime'; @@ -8,10 +9,15 @@ import { BridgeRuntime } from './bridge.runtime'; export class RuntimeResolver { constructor( private readonly bridgeRuntime: BridgeRuntime, - private readonly managedRuntime: ManagedRuntime + private readonly managedRuntime: ManagedRuntime, + private readonly humanRelayRuntime: HumanRelayRuntime ) {} resolve(agent: Pick | null): AgentRuntime { + if (agent?.runtime === 'human_relay') { + return this.humanRelayRuntime; + } + if (agent?.runtime === 'managed' && agent.managedRuntime) { return this.managedRuntime; } diff --git a/apps/api/src/app/agents/e2e/helpers/telegram-api-stub.ts b/apps/api/src/app/agents/e2e/helpers/telegram-api-stub.ts index dee412aee55..3261cbcc258 100644 --- a/apps/api/src/app/agents/e2e/helpers/telegram-api-stub.ts +++ b/apps/api/src/app/agents/e2e/helpers/telegram-api-stub.ts @@ -82,6 +82,16 @@ export async function startTelegramApiStub(): Promise { const payload = await readJsonBody(req); calls.push({ method, payload }); + // The adapter probes the non-standard `sendRichMessage*` extension before + // falling back to `sendMessage`. Real Telegram answers 404 — mirror that so + // markdown deliveries exercise the production fallback path. + if (method.startsWith('sendRichMessage')) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error_code: 404, description: 'Not Found: method not found' })); + + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(buildResponse(method, payload))); }); diff --git a/apps/api/src/app/agents/human-relay/human-action-id.spec.ts b/apps/api/src/app/agents/human-relay/human-action-id.spec.ts new file mode 100644 index 00000000000..c4e409f26f2 --- /dev/null +++ b/apps/api/src/app/agents/human-relay/human-action-id.spec.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import { + buildHumanApproveActionId, + buildHumanDenyActionId, + buildHumanDisambiguationActionId, + buildHumanOptionActionId, + parseHumanActionId, +} from './human-action-id'; + +describe('human action id grammar', () => { + it('round-trips approve and deny', () => { + expect(parseHumanActionId(buildHumanApproveActionId('hi_abc123'))).to.deep.equal({ + type: 'approve', + identifier: 'hi_abc123', + }); + expect(parseHumanActionId(buildHumanDenyActionId('hi_abc123'))).to.deep.equal({ + type: 'deny', + identifier: 'hi_abc123', + }); + }); + + it('round-trips choose options (option ids may contain separators)', () => { + expect(parseHumanActionId(buildHumanOptionActionId('hi_abc123', 'opt_2'))).to.deep.equal({ + type: 'option', + identifier: 'hi_abc123', + optionId: 'opt_2', + }); + expect(parseHumanActionId('human:hi_x:opt:a:b')).to.deep.equal({ + type: 'option', + identifier: 'hi_x', + optionId: 'a:b', + }); + }); + + it('round-trips disambiguation picks', () => { + expect(parseHumanActionId(buildHumanDisambiguationActionId('hi_abc123'))).to.deep.equal({ + type: 'disambiguation-pick', + identifier: 'hi_abc123', + }); + }); + + it('ignores foreign and malformed ids', () => { + expect(parseHumanActionId(undefined)).to.equal(null); + expect(parseHumanActionId('tool-approval:x:approve')).to.equal(null); + expect(parseHumanActionId('human:')).to.equal(null); + expect(parseHumanActionId('human:hi_x')).to.equal(null); + expect(parseHumanActionId('human:hi_x:unknown')).to.equal(null); + expect(parseHumanActionId('human:hi_x:opt:')).to.equal(null); + expect(parseHumanActionId('human:pick:')).to.equal(null); + }); +}); diff --git a/apps/api/src/app/agents/human-relay/human-action-id.ts b/apps/api/src/app/agents/human-relay/human-action-id.ts new file mode 100644 index 00000000000..49150f6cc61 --- /dev/null +++ b/apps/api/src/app/agents/human-relay/human-action-id.ts @@ -0,0 +1,67 @@ +/** + * Action-id grammar for human-interaction cards. Kept distinct from + * `tool-approval:*` / `mcp-approval:*` so `parseToolApprovalActionId` and the + * human parser can never claim each other's clicks. + * + * human::approve approve verdict + * human::deny deny verdict + * human::opt: choose pick + * human:pick: disambiguation pick ("which question?") + */ + +const PREFIX = 'human:'; +const PICK_PREFIX = 'human:pick:'; + +export type HumanActionParsed = + | { type: 'approve' | 'deny'; identifier: string } + | { type: 'option'; identifier: string; optionId: string } + | { type: 'disambiguation-pick'; identifier: string }; + +export function buildHumanApproveActionId(identifier: string): string { + return `${PREFIX}${identifier}:approve`; +} + +export function buildHumanDenyActionId(identifier: string): string { + return `${PREFIX}${identifier}:deny`; +} + +export function buildHumanOptionActionId(identifier: string, optionId: string): string { + return `${PREFIX}${identifier}:opt:${optionId}`; +} + +export function buildHumanDisambiguationActionId(identifier: string): string { + return `${PICK_PREFIX}${identifier}`; +} + +export function parseHumanActionId(actionId: string | undefined): HumanActionParsed | null { + if (!actionId?.startsWith(PREFIX)) { + return null; + } + + if (actionId.startsWith(PICK_PREFIX)) { + const identifier = actionId.slice(PICK_PREFIX.length); + + return identifier ? { type: 'disambiguation-pick', identifier } : null; + } + + const rest = actionId.slice(PREFIX.length); + const firstColon = rest.indexOf(':'); + if (firstColon <= 0) { + return null; + } + + const identifier = rest.slice(0, firstColon); + const verb = rest.slice(firstColon + 1); + + if (verb === 'approve' || verb === 'deny') { + return { type: verb, identifier }; + } + + if (verb.startsWith('opt:')) { + const optionId = verb.slice('opt:'.length); + + return optionId ? { type: 'option', identifier, optionId } : null; + } + + return null; +} diff --git a/apps/api/src/app/agents/human-relay/human-card.builder.ts b/apps/api/src/app/agents/human-relay/human-card.builder.ts new file mode 100644 index 00000000000..4dc3800a430 --- /dev/null +++ b/apps/api/src/app/agents/human-relay/human-card.builder.ts @@ -0,0 +1,194 @@ +import type { HumanInteractionEntity } from '@novu/dal'; +import { HumanInteractionKindEnum, HumanInteractionStatusEnum, type HumanInteractionOption } from '@novu/shared'; +import type { ActionsElement, ButtonElement, CardElement, TextElement } from 'chat'; +import { + buildHumanApproveActionId, + buildHumanDenyActionId, + buildHumanDisambiguationActionId, + buildHumanOptionActionId, +} from './human-action-id'; +import type { ReplyContentDto } from '../shared/dtos/agent-reply-payload.dto'; + +const LISTED_OPTION_LABEL_MAX = 200; + +/** + * Letters, not full labels, on `choose`/disambiguation buttons. Chat platform + * button UIs (Telegram inline keyboards especially) truncate or wrap long + * label text badly; the full text is always listed in the message body + * instead, and the button just needs to be tappable and short. + */ +const OPTION_LETTERS = 'ABCDEFGHIJ'; + +function optionLetter(index: number): string { + return OPTION_LETTERS[index] ?? String(index + 1); +} + +function button(id: string, label: string, style: 'default' | 'primary'): ButtonElement { + return { type: 'button', id, label, style }; +} + +function textChild(content: string): TextElement { + return { type: 'text', content }; +} + +/** + * The portable-card renderer joins title/subtitle/body with a single `\n` + * (no blank line), which reads as one cramped block. A leading `\n` on the + * first body text child turns that single join newline into a real blank + * line — use this for whichever text child comes right after title/subtitle. + */ +function bodyText(content: string): TextElement { + return textChild(`\n${content}`); +} + +/** "**A.**