diff --git a/apps/api/src/app/human/dtos/list-contacts.dto.ts b/apps/api/src/app/human/dtos/list-contacts.dto.ts new file mode 100644 index 00000000000..5d5863d2c17 --- /dev/null +++ b/apps/api/src/app/human/dtos/list-contacts.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export const DEFAULT_CONTACTS_LIMIT = 50; +export const MAX_CONTACTS_LIMIT = 100; + +export class ListContactsQueryDto { + @ApiPropertyOptional({ default: DEFAULT_CONTACTS_LIMIT, maximum: MAX_CONTACTS_LIMIT }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_CONTACTS_LIMIT) + limit?: number; + + @ApiPropertyOptional({ description: 'Cursor from a previous page’s `next` — returns contacts after it.' }) + @IsOptional() + @IsString() + after?: string; +} + +/** + * A contact is a subscriber, viewed as "someone an agent can talk to". + * Only the human-facing subset of the subscriber is exposed — internal ids, + * legacy `channels`, and topic membership stay out of the contract. + */ +export class HumanContactDto { + @ApiProperty({ description: 'The subscriberId — pass it to `--to`.' }) + id: string; + + @ApiPropertyOptional() + firstName?: string; + + @ApiPropertyOptional() + lastName?: string; + + @ApiPropertyOptional() + email?: string; + + @ApiPropertyOptional() + phone?: string; + + @ApiPropertyOptional({ + description: 'Free-form custom data on the subscriber (e.g. role notes).', + type: 'object', + additionalProperties: true, + }) + data?: Record; + + @ApiProperty() + createdAt: string; + + @ApiProperty() + updatedAt: string; +} + +export class ListContactsResponseDto { + @ApiProperty({ type: [HumanContactDto] }) + data: HumanContactDto[]; + + @ApiProperty({ nullable: true, description: 'Cursor for the next page, or null when this is the last page.' }) + next: string | null; +} diff --git a/apps/api/src/app/human/dtos/setup-human-relay.dto.ts b/apps/api/src/app/human/dtos/setup-human-relay.dto.ts index 4ede1fae80f..5a759b3c40b 100644 --- a/apps/api/src/app/human/dtos/setup-human-relay.dto.ts +++ b/apps/api/src/app/human/dtos/setup-human-relay.dto.ts @@ -21,6 +21,20 @@ export class SetupHumanRelayRequestDto { @IsOptional() @IsEmail() email?: string; + + @ApiPropertyOptional({ + description: 'The human’s first name (display name shown to agents and in reply attribution).', + }) + @IsOptional() + @IsString() + @MaxLength(128) + firstName?: string; + + @ApiPropertyOptional({ description: 'The human’s last name.' }) + @IsOptional() + @IsString() + @MaxLength(128) + lastName?: string; } export class SetupHumanRelayResponseDto { diff --git a/apps/api/src/app/human/e2e/human-contacts.e2e.ts b/apps/api/src/app/human/e2e/human-contacts.e2e.ts new file mode 100644 index 00000000000..c426f91d45f --- /dev/null +++ b/apps/api/src/app/human/e2e/human-contacts.e2e.ts @@ -0,0 +1,116 @@ +import { SubscriberRepository } from '@novu/dal'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; + +const subscriberRepository = new SubscriberRepository(); + +describe('Human contacts (setup names → list) #novu-v2', () => { + let session: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + async function setup(body: Record) { + const res = await session.testAgent.post('/v1/human/setup').send(body); + expect(res.status).to.equal(200, JSON.stringify(res.body)); + + return res.body.data as { subscriberId: string }; + } + + async function findSubscriber(subscriberId: string) { + return subscriberRepository.findOne({ subscriberId, _environmentId: session.environment._id }); + } + + describe('POST /v1/human/setup names', () => { + it('creates the subscriber with firstName and lastName', async () => { + const subscriberId = `contact-${Date.now()}`; + await setup({ subscriberId, firstName: 'Alice', lastName: 'Chen' }); + + const subscriber = await findSubscriber(subscriberId); + expect(subscriber?.firstName).to.equal('Alice'); + expect(subscriber?.lastName).to.equal('Chen'); + }); + + it('replaces the name on re-setup and keeps it when omitted', async () => { + const subscriberId = `contact-${Date.now()}`; + await setup({ subscriberId, firstName: 'Alice' }); + await setup({ subscriberId, firstName: 'Alicia', lastName: 'Chen' }); + + let subscriber = await findSubscriber(subscriberId); + expect(subscriber?.firstName).to.equal('Alicia'); + expect(subscriber?.lastName).to.equal('Chen'); + + await setup({ subscriberId }); + subscriber = await findSubscriber(subscriberId); + expect(subscriber?.firstName).to.equal('Alicia'); + expect(subscriber?.lastName).to.equal('Chen'); + }); + }); + + describe('GET /v1/human/contacts', () => { + it('lists every subscriber in the environment with only contact fields', async () => { + const stamp = Date.now(); + await setup({ subscriberId: `alice-${stamp}`, firstName: 'Alice', lastName: 'Chen' }); + await setup({ subscriberId: `bob-${stamp}`, email: 'bob@example.com' }); + await subscriberRepository.create({ + subscriberId: `carol-${stamp}`, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + phone: '+15550000000', + data: { role: 'on-call' }, + }); + + const res = await session.testAgent.get('/v1/human/contacts'); + expect(res.status).to.equal(200, JSON.stringify(res.body)); + + const rows = res.body.data as Array>; + const byId = new Map(rows.map((row) => [row.id as string, row])); + expect(byId.has(`alice-${stamp}`)).to.equal(true); + expect(byId.has(`bob-${stamp}`)).to.equal(true); + expect(byId.has(`carol-${stamp}`)).to.equal(true); + + const alice = byId.get(`alice-${stamp}`); + expect(alice?.firstName).to.equal('Alice'); + expect(alice?.lastName).to.equal('Chen'); + expect(byId.get(`bob-${stamp}`)?.email).to.equal('bob@example.com'); + + const carol = byId.get(`carol-${stamp}`); + expect(carol?.phone).to.equal('+15550000000'); + expect(carol?.data).to.deep.equal({ role: 'on-call' }); + expect(carol?.createdAt).to.be.a('string'); + expect(carol?.updatedAt).to.be.a('string'); + + const allowedKeys = new Set(['id', 'firstName', 'lastName', 'email', 'phone', 'data', 'createdAt', 'updatedAt']); + for (const row of rows) { + for (const key of Object.keys(row)) { + expect(allowedKeys.has(key), `unexpected contact field "${key}"`).to.equal(true); + } + } + }); + + it('pages with limit and after', async () => { + const stamp = Date.now(); + await setup({ subscriberId: `p1-${stamp}` }); + await setup({ subscriberId: `p2-${stamp}` }); + + const first = await session.testAgent.get('/v1/human/contacts').query({ limit: 1 }); + expect(first.status).to.equal(200); + expect(first.body.data).to.have.length(1); + expect(first.body.next).to.be.a('string'); + + const second = await session.testAgent.get('/v1/human/contacts').query({ limit: 1, after: first.body.next }); + expect(second.status).to.equal(200); + expect(second.body.data).to.have.length(1); + expect(second.body.data[0].id).to.not.equal(first.body.data[0].id); + }); + + it('returns an empty page for a malformed cursor', async () => { + const res = await session.testAgent.get('/v1/human/contacts').query({ after: 'not-a-cursor' }); + expect(res.status).to.equal(200); + expect(res.body.data).to.deep.equal([]); + expect(res.body.next).to.equal(null); + }); + }); +}); diff --git a/apps/api/src/app/human/human-interactions.controller.ts b/apps/api/src/app/human/human-interactions.controller.ts index 4ca7319c2bd..2cc13c51548 100644 --- a/apps/api/src/app/human/human-interactions.controller.ts +++ b/apps/api/src/app/human/human-interactions.controller.ts @@ -20,6 +20,7 @@ import { KeylessAccessible } from '../shared/framework/swagger/keyless.security' import { UserSession } from '../shared/framework/user.decorator'; import { CreateInteractionRequestDto } from './dtos/create-interaction-request.dto'; import { InteractionResponseDto } from './dtos/interaction-response.dto'; +import { ListContactsQueryDto, ListContactsResponseDto } from './dtos/list-contacts.dto'; import { ListInteractionsQueryDto } from './dtos/list-interactions-query.dto'; import { SetupHumanRelayRequestDto, SetupHumanRelayResponseDto } from './dtos/setup-human-relay.dto'; import { CancelInteractionCommand } from './usecases/cancel-interaction/cancel-interaction.command'; @@ -28,6 +29,8 @@ import { CreateInteractionCommand } from './usecases/create-interaction/create-i import { CreateInteraction } from './usecases/create-interaction/create-interaction.usecase'; import { GetInteractionCommand } from './usecases/get-interaction/get-interaction.command'; import { GetInteraction } from './usecases/get-interaction/get-interaction.usecase'; +import { ListContactsCommand } from './usecases/list-contacts/list-contacts.command'; +import { ListContacts } from './usecases/list-contacts/list-contacts.usecase'; import { ListInteractionsCommand } from './usecases/list-interactions/list-interactions.command'; import { ListInteractions } from './usecases/list-interactions/list-interactions.usecase'; import { SetupHumanRelayCommand } from './usecases/setup-human-relay/setup-human-relay.command'; @@ -44,7 +47,8 @@ export class HumanInteractionsController { private readonly getInteractionUsecase: GetInteraction, private readonly listInteractionsUsecase: ListInteractions, private readonly cancelInteractionUsecase: CancelInteraction, - private readonly setupHumanRelayUsecase: SetupHumanRelay + private readonly setupHumanRelayUsecase: SetupHumanRelay, + private readonly listContactsUsecase: ListContacts ) {} @Post('/interactions') @@ -130,6 +134,30 @@ export class HumanInteractionsController { ); } + /** + * Contacts are the environment's subscribers — the people an agent can + * address with `--to`. Deliberately a thin subscriber list today; filters + * and a per-contact `channels` field are the intended extension points. + */ + @Get('/contacts') + @KeylessAccessible() + @ExternalApiAccessible() + @RequirePermissions(PermissionsEnum.AGENT_READ) + listContacts( + @UserSession() user: UserSessionData, + @Query() query: ListContactsQueryDto + ): Promise { + return this.listContactsUsecase.execute( + ListContactsCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + userId: user._id, + limit: query.limit, + after: query.after, + }) + ); + } + @Post('/setup') @HttpCode(HttpStatus.OK) @KeylessAccessible() @@ -147,6 +175,8 @@ export class HumanInteractionsController { subscriberId: body.subscriberId, agentIdentifier: body.agentIdentifier, email: body.email, + firstName: body.firstName, + lastName: body.lastName, }) ); } diff --git a/apps/api/src/app/human/human.module.ts b/apps/api/src/app/human/human.module.ts index 4f7dbd1a7f3..b1339a9d9d5 100644 --- a/apps/api/src/app/human/human.module.ts +++ b/apps/api/src/app/human/human.module.ts @@ -14,6 +14,7 @@ import { HumanDeliveryService } from './services/human-delivery.service'; import { CancelInteraction } from './usecases/cancel-interaction/cancel-interaction.usecase'; import { CreateInteraction } from './usecases/create-interaction/create-interaction.usecase'; import { GetInteraction } from './usecases/get-interaction/get-interaction.usecase'; +import { ListContacts } from './usecases/list-contacts/list-contacts.usecase'; import { ListInteractions } from './usecases/list-interactions/list-interactions.usecase'; import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay.usecase'; @@ -37,6 +38,7 @@ import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay. ListInteractions, CancelInteraction, SetupHumanRelay, + ListContacts, ], }) export class HumanModule {} diff --git a/apps/api/src/app/human/usecases/list-contacts/list-contacts.command.ts b/apps/api/src/app/human/usecases/list-contacts/list-contacts.command.ts new file mode 100644 index 00000000000..adb02e9b54e --- /dev/null +++ b/apps/api/src/app/human/usecases/list-contacts/list-contacts.command.ts @@ -0,0 +1,15 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { MAX_CONTACTS_LIMIT } from '../../dtos/list-contacts.dto'; + +export class ListContactsCommand extends EnvironmentWithUserCommand { + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_CONTACTS_LIMIT) + limit?: number; + + @IsOptional() + @IsString() + after?: string; +} diff --git a/apps/api/src/app/human/usecases/list-contacts/list-contacts.usecase.ts b/apps/api/src/app/human/usecases/list-contacts/list-contacts.usecase.ts new file mode 100644 index 00000000000..94d5bdf0118 --- /dev/null +++ b/apps/api/src/app/human/usecases/list-contacts/list-contacts.usecase.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { BaseRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; +import { DirectionEnum } from '../../../shared/dtos/base-responses'; +import { DEFAULT_CONTACTS_LIMIT, HumanContactDto, ListContactsResponseDto } from '../../dtos/list-contacts.dto'; +import { ListContactsCommand } from './list-contacts.command'; + +/** + * Lists the environment's subscribers as contacts. Backed by the same + * repository pagination as `GET /v2/subscribers`, but exposed under + * `/v1/human` so it is keyless-reachable by the `human` CLI and can grow + * human-specific filters (and a per-contact `channels` field) later. + */ +@Injectable() +export class ListContacts { + constructor(private readonly subscriberRepository: SubscriberRepository) {} + + @InstrumentUsecase() + async execute(command: ListContactsCommand): Promise { + // A cursor that is not an internal id can never match a row; return an + // empty page instead of letting the repository throw on a bad ObjectId. + if (command.after && !BaseRepository.isInternalId(command.after)) { + return { data: [], next: null }; + } + + const page = await this.subscriberRepository.listSubscribers({ + environmentId: command.environmentId, + organizationId: command.organizationId, + limit: command.limit ?? DEFAULT_CONTACTS_LIMIT, + after: command.after, + sortBy: '_id', + sortDirection: DirectionEnum.DESC, + }); + + return { + data: page.subscribers.map(toContact), + next: page.next, + }; + } +} + +function toContact(subscriber: SubscriberEntity): HumanContactDto { + return { + id: subscriber.subscriberId, + ...(subscriber.firstName ? { firstName: subscriber.firstName } : {}), + ...(subscriber.lastName ? { lastName: subscriber.lastName } : {}), + ...(subscriber.email ? { email: subscriber.email } : {}), + ...(subscriber.phone ? { phone: subscriber.phone } : {}), + ...(subscriber.data ? { data: subscriber.data } : {}), + createdAt: subscriber.createdAt, + updatedAt: subscriber.updatedAt, + }; +} diff --git a/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.command.ts b/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.command.ts index cd5d506f249..d727a7814ac 100644 --- a/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.command.ts +++ b/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.command.ts @@ -13,4 +13,12 @@ export class SetupHumanRelayCommand extends EnvironmentWithUserCommand { @IsOptional() @IsString() email?: string; + + @IsOptional() + @IsString() + firstName?: string; + + @IsOptional() + @IsString() + lastName?: string; } diff --git a/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.usecase.ts b/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.usecase.ts index fa2bb911219..1c72af5e001 100644 --- a/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.usecase.ts +++ b/apps/api/src/app/human/usecases/setup-human-relay/setup-human-relay.usecase.ts @@ -1,6 +1,6 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; -import { AgentEntity, AgentRepository, SubscriberRepository } from '@novu/dal'; +import { AgentEntity, AgentRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { AgentSubscriberAccessEnum } from '@novu/shared'; import type { SetupHumanRelayResponseDto } from '../../dtos/setup-human-relay.dto'; import { SetupHumanRelayCommand } from './setup-human-relay.command'; @@ -71,6 +71,8 @@ export class SetupHumanRelay { private async ensureSubscriber(command: SetupHumanRelayCommand): Promise { const email = command.email?.trim().toLowerCase(); + const firstName = command.firstName?.trim() || undefined; + const lastName = command.lastName?.trim() || undefined; const existing = await this.subscriberRepository.findOne({ subscriberId: command.subscriberId, @@ -80,10 +82,17 @@ export class SetupHumanRelay { if (existing) { // Email identity powers the email channel (delivery target + inbound // reply resolution live on Subscriber.email — no ChannelEndpoint). - if (email && existing.email !== email) { + // Names are only ever set or replaced, never cleared: an invite that + // omits `--name` must not wipe a name captured earlier. + const updates: Partial> = {}; + if (email && existing.email !== email) updates.email = email; + if (firstName && existing.firstName !== firstName) updates.firstName = firstName; + if (lastName && existing.lastName !== lastName) updates.lastName = lastName; + + if (Object.keys(updates).length > 0) { await this.subscriberRepository.update( { subscriberId: command.subscriberId, _environmentId: command.environmentId }, - { $set: { email } } + { $set: updates } ); } @@ -95,6 +104,8 @@ export class SetupHumanRelay { _environmentId: command.environmentId, _organizationId: command.organizationId, ...(email ? { email } : {}), + ...(firstName ? { firstName } : {}), + ...(lastName ? { lastName } : {}), }); } } diff --git a/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx b/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx index 22f356e74f3..4d8dc977e40 100644 --- a/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx +++ b/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx @@ -133,7 +133,7 @@ export function ChatMessageRow({ showAvatar: boolean; onCardAction?: CardActionHandler; cardActionsDisabled?: boolean; - onRespondToAction?: (args: { actionId: string; decision: ToolApprovalDecision }) => void; + onRespondToAction?: (args: { approvalId: string; decision: ToolApprovalDecision }) => void; onRetry?: (messageId: string) => void; }) { const isUser = message.role === 'user'; @@ -242,7 +242,7 @@ export function ChatMessageRow({ trustServerActionId={part.trustServerActionId} disabled={cardActionsDisabled} onRespond={ - onRespondToAction ? (decision) => onRespondToAction({ actionId: part.approvalId, decision }) : undefined + onRespondToAction ? (decision) => onRespondToAction({ approvalId: part.approvalId, decision }) : undefined } /> ))} diff --git a/docs/agents/channels/web-chat/chat-ui.mdx b/docs/agents/channels/web-chat/chat-ui.mdx index 2a6a028d94e..cd01b44ca9e 100644 --- a/docs/agents/channels/web-chat/chat-ui.mdx +++ b/docs/agents/channels/web-chat/chat-ui.mdx @@ -90,7 +90,7 @@ The first event of a turn can create an empty assistant message before any text | `action.type` | What to do | | --- | --- | -| `tool-approval` | Call `respondToAction` with `action.id` and `approved` or `denied`. | +| `approval` | Call `respondToAction` with `action.approvalId` and `approved` or `denied`. | | `mcp-connection` | Open `action.authorizeUrl` in the browser. Do not call `respondToAction`. | ### Tool approval @@ -101,15 +101,15 @@ const { pendingActions, respondToAction } = useWebChat({ }); {pendingActions.map((action) => { - if (action.type !== 'tool-approval') { + if (action.type !== 'approval') { return null; } return ( @@ -117,7 +117,7 @@ const { pendingActions, respondToAction } = useWebChat({ })} ``` -Pass `action.id` from `pendingActions`. Do not invent approve or deny ids. +Pass `action.approvalId` from `pendingActions`. Do not invent approve or deny ids. ### MCP connect @@ -132,7 +132,7 @@ const { pendingActions } = useWebChat({ } return ( - + Connect {action.displayName} ); diff --git a/docs/platform/sdks/javascript.mdx b/docs/platform/sdks/javascript.mdx index dea8061e6ef..06c2446f660 100644 --- a/docs/platform/sdks/javascript.mdx +++ b/docs/platform/sdks/javascript.mdx @@ -803,7 +803,7 @@ conversation.dispose(); | `subscribe(listener)` | Call `listener` on every snapshot. Returns a stop function. | | `sendMessage(input)` | Send a user message. `input` is a string, or `{ text, metadata }`. Creates a conversation when `conversationId` is omitted. | | `retryMessage(messageId)` | Resend a message whose `status` is `failed`. Reuses the original idempotency key. | -| `respondToAction({ actionId, decision })` | Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. | +| `respondToAction({ approvalId, decision })` | Resolve a pending `approval`. Pass `approvalId` from `pendingActions` or message parts. | | `sendAction({ actionId, sourceMessageId, value })` | Click a Card button. Do not use this for tool approval. | | `fetchMore()` | Load the next older history page. | | `load()` | Reload the newest history page. Runs on resume when `conversationId` is set. | diff --git a/docs/platform/sdks/react/hooks/use-web-chat.mdx b/docs/platform/sdks/react/hooks/use-web-chat.mdx index 0ba37b95631..125dc4b08a7 100644 --- a/docs/platform/sdks/react/hooks/use-web-chat.mdx +++ b/docs/platform/sdks/react/hooks/use-web-chat.mdx @@ -43,7 +43,7 @@ Pass `agentId` (and optional `conversationId` and `agentHash`), or pass `convers | `refetch` | `() => Promise` | Reload the newest history page. No-op when there is no conversation id. | | `sendMessage` | `(input: SendMessageInput) => Promise<{ data?: SendMessageResult; error?: NovuError \| WebChatPlanLimitError }>` | Send a user message. `input` is a string, or `{ text, metadata }`. Creates a conversation when `conversationId` is omitted. | | `retryMessage` | `(messageId: string) => Promise<{ data?: SendMessageResult; error?: NovuError \| WebChatPlanLimitError }>` | Resend a message whose `status` is `failed`. Reuses the original idempotency key. Does not create a second message. See [Retry a failed send](/agents/channels/web-chat/chat-ui#retry-a-failed-send). | -| `respondToAction` | `(args: { actionId: string; decision: AgentToolApprovalDecision }) => Promise<{ data?: RespondToActionResult; error?: NovuError \| WebChatPlanLimitError }>` | Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. Typical decisions: `'approved'` or `'denied'`. | +| `respondToAction` | `(args: { approvalId: string; decision: AgentToolApprovalDecision }) => Promise<{ data?: RespondToActionResult; error?: NovuError \| WebChatPlanLimitError }>` | Resolve a pending `approval`. Pass `approvalId` from `pendingActions` or message parts. Typical decisions: `'approved'` or `'denied'`. | | `sendAction` | `(args: { actionId: string; sourceMessageId: string; value?: string }) => Promise<{ data?: SendActionResult; error?: NovuError \| WebChatPlanLimitError }>` | Click a Card button. Pass `id` / `value` from the button and `message.id` as `sourceMessageId`. Do not use this for tool approval. See [Cards](/agents/channels/web-chat/chat-ui#cards). | ## Pagination @@ -99,7 +99,7 @@ See [Reconnect](/agents/channels/web-chat/chat-ui#reconnect). | `type` | What to do | | --- | --- | -| `tool-approval` | Call `respondToAction({ actionId: action.id, decision: 'approved' \| 'denied' })`. | +| `approval` | Call `respondToAction({ approvalId: action.approvalId, decision: 'approved' \| 'denied' })`. | | `mcp-connection` | Open `authorizeUrl` in the browser. Do not invent action ids. | ## Plan limit error diff --git a/packages/human/README.md b/packages/human/README.md index 7b845ec9bc9..ec2998f117c 100644 --- a/packages/human/README.md +++ b/packages/human/README.md @@ -16,15 +16,20 @@ human choose "Pick a release strategy" --option canary --option blue-green human tell "Nightly build finished — 0 failures." # Link another human (they open the URL; does not change your local identity): -human invite alice --via slack +human invite alice --via slack --name "Alice Chen" human invite bob --via telegram --async human invite carol --via email --email carol@acme.com + +# See who agents can reach (subscribers in the environment; `(you)` marks the operator): +human contacts +human contacts --json ``` ## How it works - `setup` provisions a keyless Novu environment (no account needed), a hidden relay agent, and links **your** channel — Telegram via QR, Slack via app install, Email by registering your address (approvals arrive as button emails; answer asks by replying). Run it again with another channel to add more. Linked channels live on the server; `human channels --default slack` sets a local preference for where interactions land when you don't pass `--via`. -- `invite` links a **different** subscriber the same way Slack/Telegram connect does (OAuth or a deep link → channel endpoint). Send them the URL; your `~/.novu/human.json` subscriberId stays yours. Then `--to alice` can reach them. +- `invite` links a **different** subscriber the same way Slack/Telegram connect does (OAuth or a deep link → channel endpoint). Send them the URL; your `~/.novu/human.json` subscriberId stays yours. Then `--to alice` can reach them. Pass `--name "Alice Chen"` so they show up by name. +- `contacts` lists the environment's subscribers — every person `--to` can address — so an agent can check who exists before coordinating between people. It's a directory, not a reachability check: if delivery fails with "no linked endpoint", `invite` them on that channel. - Agents stay channel-blind: routing is the human's preference. `--via telegram|slack|email` on ask/approve is a rare per-call **delivery** override, not how you onboard someone. - Each command delivers a one-off message (with action buttons where relevant) and **blocks** until the human answers, the `--ttl` expires, or `--timeout` elapses. - Answers flow back through button clicks or plain replies; the CLI resolves and your agent continues. @@ -46,7 +51,7 @@ human invite carol --via email --email carol@acme.com - `--timeout 10m` — max time this invocation blocks; on timeout it prints the id so `human wait ` can resume. - `--async` — don't block; print the interaction id immediately. - `--json` — full interaction object for programmatic parsing. -- `--to ` — address a human who is already linked (`human invite` first), or comma-separated humans (`alice,bob`, max 50) so any listed person can settle. +- `--to ` — address a human who is already linked (`human contacts` to find them, `human invite` to add them), or comma-separated humans (`alice,bob`, max 50) so any listed person can settle. - `--via ` — deliver on a specific linked channel instead of the default. ## Auth & headless use diff --git a/packages/human/site/index.html b/packages/human/site/index.html index 9e3e150dd03..ad5356c07a2 100644 --- a/packages/human/site/index.html +++ b/packages/human/site/index.html @@ -34,6 +34,8 @@ human approve "Delete 342 stale records from prod?" human choose "Pick a release strategy" --option canary --option blue-green human tell "Nightly build finished — 0 failures." + human contacts # who --to can address + human contacts --json HOW IT WORKS ------------ @@ -49,6 +51,9 @@ --timeout elapses. - Answers flow back through button clicks or plain replies; the CLI resolves and your agent continues. + - `contacts` lists every subscriber `--to` can address so an agent + can check who exists before coordinating. It's a directory, not a + reachability check. EXIT CODES (stable contract for agents) ---------------------------------------- @@ -67,18 +72,31 @@ --async don't block; print the interaction id immediately --json full interaction object for programmatic parsing --to address a linked human, or comma-separated humans - (link others first with `human invite`) + (find them with `human contacts`; link others + first with `human invite`) --via deliver on a specific linked channel instead of default INVITE (link another human) --------------------------- - human invite alice --via slack + human invite alice --via slack --name "Alice Chen" human invite bob --via telegram --async - human invite carol --via email --email carol@acme.com + human invite carol --via email --email carol@acme.com --name "Carol Diaz" Prints a connect URL for that person (same OAuth / Telegram deep-link as setup). Does not change your local subscriberId. After they connect, - address them with --to. + address them with --to. Pass --name so they show up by name in contacts. + +CONTACTS (who agents can reach) +------------------------------- + human contacts + human contacts --json + human contacts --limit 20 --after + + Lists every subscriber in the environment — the directory `--to` + addresses. The operator's row is marked (you) / self: true. A directory, + not a reachability check: if delivery fails with "no linked endpoint", + invite them on that channel. Pages are 50 by default (max 100); when + next is set, continue with --after. AUTH ---- diff --git a/packages/human/site/sapien.html b/packages/human/site/sapien.html index ae34bb45708..5b0c7e0faa8 100644 --- a/packages/human/site/sapien.html +++ b/packages/human/site/sapien.html @@ -256,6 +256,17 @@ margin: 0 0 12px; } + .session-copy code { + font-family: "Geist Mono", monospace; + font-size: 0.8em; + color: var(--text); + background: var(--bg-raise); + border: 1px solid var(--line); + padding: 1px 5px; + } + + .session.rule { border-top: 1px solid var(--line); } + .terminal { border: 1px solid var(--line-strong); background: var(--bg-raise); @@ -333,6 +344,9 @@ color: var(--text-dim); } + .terminal-body .you { color: #7eb8d4; } + .terminal-body .hdr { color: var(--text-faint); } + /* ---------- steps ---------- */ .steps { @@ -391,32 +405,6 @@ padding: 1px 5px; } - /* ---------- exit codes ---------- */ - - .codes { - border-top: 1px solid var(--line); - padding: clamp(64px, 9vw, 110px) clamp(20px, 4vw, 48px); - } - - .codes-table { - margin-top: 44px; - border-top: 1px solid var(--line); - font-family: "Geist Mono", monospace; - font-size: 0.8rem; - } - - .code-row { - display: grid; - grid-template-columns: 72px 1fr; - gap: 20px; - padding: 13px 4px; - border-bottom: 1px solid var(--line); - color: var(--text-dim); - } - - .code-row .n { color: var(--text); font-weight: 500; } - .code-row.ember .n { color: var(--ember); } - /* ---------- quote ---------- */ .quote { @@ -533,8 +521,8 @@

Your agents can reach everything — except you.<

The agent waits.
You decide.

Every blocking call delivers a message with buttons to wherever you - already are, then holds the process until you tap one. Exit codes carry - the verdict — your scripts branch on the answer, not on hope.

+ already are, then holds the process until you tap one. Your scripts + branch on the answer, not on hope.

Timeouts, retries and resumption are the agent's problem. Yours is a five-second decision from your phone.

@@ -560,7 +548,7 @@

The agent waits.
You decide.

approve -

Approve / Deny buttons for anything irreversible. Exit 0 or 10. An audit trail of who said yes.

+

Approve / Deny buttons for anything irreversible. An audit trail of who said yes.

choose @@ -572,6 +560,31 @@

The agent waits.
You decide.

+
+
+

More than one human.
Look first.

+

Agents can list everyone they can address with + human contacts — a directory of people in the + environment, not a guarantee they have a channel linked yet.

+

Your row is marked (you). Invite someone new with + human invite alice --via slack --name "Alice Chen", + then pass their id to --to.

+
+
+
agent — who can I reachtty
+
+
$ human contacts
+
ID NAME EMAIL
+
dima Dima Groza dima@novu.co (you)
+
alice Alice Chen alice@acme.com
+
bob Bob —
+
 
+
$ human approve "Ship the pricing change?" --to alice
+
+
+
+
+

Three minutes, once.

@@ -596,17 +609,6 @@

You answer from your phone

-
-

Exit codes are the contract.

-
-
0answered / approved / chosen / delivered
-
10denied — the human said no
-
11timed out waiting — still pending, resume with human wait <id>
-
12expired or canceled
-
1error
-
-
-
Agents are connected to everything except diff --git a/packages/human/src/api/human.ts b/packages/human/src/api/human.ts index f5ef7d9ed78..b51c93c73c8 100644 --- a/packages/human/src/api/human.ts +++ b/packages/human/src/api/human.ts @@ -74,7 +74,7 @@ export async function cancelInteraction(client: HumanApiClient, id: string): Pro export async function setupHumanRelay( client: HumanApiClient, - input: { subscriberId: string; agentIdentifier?: string; email?: string } + input: { subscriberId: string; agentIdentifier?: string; email?: string; firstName?: string; lastName?: string } ): Promise<{ agentId: string; agentIdentifier: string; subscriberId: string }> { const res = await client.axios.post< | { data?: { agentId: string; agentIdentifier: string; subscriberId: string } } @@ -87,3 +87,30 @@ export async function setupHumanRelay( return unwrap(res.data); } + +/** A contact is a subscriber in the environment — `id` is the subscriberId `--to` addresses. */ +export interface Contact { + id: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + data?: Record; + createdAt: string; + updatedAt: string; +} + +export interface ContactsPage { + data: Contact[]; + next: string | null; +} + +export async function listContacts( + client: HumanApiClient, + params: { limit?: number; after?: string } = {} +): Promise { + const res = await client.axios.get<{ data?: Contact[]; next?: string | null }>('/v1/human/contacts', { params }); + const body = res.data; + + return { data: Array.isArray(body?.data) ? body.data : [], next: body?.next ?? null }; +} diff --git a/packages/human/src/commands/contacts.spec.ts b/packages/human/src/commands/contacts.spec.ts new file mode 100644 index 00000000000..bec3ef548bf --- /dev/null +++ b/packages/human/src/commands/contacts.spec.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Contact } from '../api/human'; +import type { HumanCliConfig } from '../config'; + +const listContacts = vi.fn(); +const clientFromConfig = vi.fn(); + +vi.mock('../api/human', () => ({ + listContacts: (...args: unknown[]) => listContacts(...args), +})); + +vi.mock('./interact', async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + clientFromConfig: (...args: unknown[]) => clientFromConfig(...args), + }; +}); + +const { contactsCommand, markSelf, parseContactsLimit, renderContactsTable, displayName } = await import('./contacts'); + +const config: HumanCliConfig = { + apiUrl: 'https://api.novu.co', + auth: { mode: 'apiKey', secretKey: 'key' }, + relayAgentIdentifier: 'human-relay', + subscriberId: 'human_me', +}; + +function contact(overrides: Partial & { id: string }): Contact { + return { createdAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-01T00:00:00.000Z', ...overrides }; +} + +describe('parseContactsLimit', () => { + it('defaults to 50 and rejects out-of-range values', () => { + expect(parseContactsLimit(undefined)).toBe(50); + expect(parseContactsLimit('5')).toBe(5); + expect(() => parseContactsLimit('0')).toThrow('--limit'); + expect(() => parseContactsLimit('101')).toThrow('--limit'); + expect(() => parseContactsLimit('abc')).toThrow('--limit'); + }); +}); + +describe('markSelf', () => { + it('flags only the operator row', () => { + const rows = markSelf([contact({ id: 'alice' }), contact({ id: 'human_me' })], 'human_me'); + expect(rows.map((row) => row.self)).toEqual([false, true]); + }); + + it('flags nothing when the config has no subscriberId', () => { + const rows = markSelf([contact({ id: 'alice' })], undefined); + expect(rows[0].self).toBe(false); + }); +}); + +describe('displayName', () => { + it('joins first and last name and tolerates either missing', () => { + expect(displayName(contact({ id: 'a', firstName: 'Alice', lastName: 'Chen' }))).toBe('Alice Chen'); + expect(displayName(contact({ id: 'a', firstName: 'Alice' }))).toBe('Alice'); + expect(displayName(contact({ id: 'a' }))).toBe(''); + }); +}); + +describe('renderContactsTable', () => { + it('prints an empty-state hint', () => { + expect(renderContactsTable([], null)).toContain('No contacts found'); + }); + + it('marks (you) and hints when more pages exist', () => { + const out = renderContactsTable( + markSelf( + [contact({ id: 'alice', firstName: 'Alice', email: 'a@x.co' }), contact({ id: 'human_me' })], + 'human_me' + ), + 'cursor-1' + ); + expect(out).toContain('alice'); + expect(out).toContain('Alice'); + expect(out).toContain('a@x.co'); + expect(out).toContain('(you)'); + expect(out).toContain('More contacts'); + expect(out).toContain('--after cursor-1'); + }); + + it('omits the hint on the last page', () => { + expect(renderContactsTable(markSelf([contact({ id: 'alice' })], 'human_me'), null)).not.toContain('More contacts'); + }); +}); + +describe('contactsCommand', () => { + let stdout: string; + + beforeEach(() => { + stdout = ''; + listContacts.mockReset(); + clientFromConfig.mockReset(); + clientFromConfig.mockReturnValue({ client: {}, config }); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout += String(chunk); + + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${code ?? 0}`); + }) as never); + }); + + it('prints { data, next } JSON with self markers', async () => { + listContacts.mockResolvedValue({ + data: [contact({ id: 'human_me', firstName: 'Dima' }), contact({ id: 'alice' })], + next: 'cursor-2', + }); + + await expect(contactsCommand({ json: true, limit: '10' })).rejects.toThrow('exit:0'); + + expect(listContacts).toHaveBeenCalledWith({}, { limit: 10 }); + const parsed = JSON.parse(stdout); + expect(parsed.next).toBe('cursor-2'); + expect(parsed.data.map((row: { id: string; self: boolean }) => [row.id, row.self])).toEqual([ + ['human_me', true], + ['alice', false], + ]); + }); + + it('renders the table by default', async () => { + listContacts.mockResolvedValue({ data: [contact({ id: 'alice', firstName: 'Alice' })], next: null }); + + await expect(contactsCommand({})).rejects.toThrow('exit:0'); + + expect(stdout).toContain('alice'); + expect(stdout).toContain('Alice'); + expect(listContacts).toHaveBeenCalledWith({}, { limit: 50 }); + }); + + it('passes --after through as the page cursor', async () => { + listContacts.mockResolvedValue({ data: [contact({ id: 'bob' })], next: null }); + + await expect(contactsCommand({ after: 'cursor-2', limit: '25' })).rejects.toThrow('exit:0'); + + expect(listContacts).toHaveBeenCalledWith({}, { limit: 25, after: 'cursor-2' }); + expect(stdout).toContain('bob'); + expect(stdout).not.toContain('More contacts'); + }); +}); diff --git a/packages/human/src/commands/contacts.ts b/packages/human/src/commands/contacts.ts new file mode 100644 index 00000000000..996aa4dc498 --- /dev/null +++ b/packages/human/src/commands/contacts.ts @@ -0,0 +1,90 @@ +import pc from 'picocolors'; +import { type Contact, listContacts } from '../api/human'; +import { clientFromConfig, handleError } from './interact'; + +export const DEFAULT_CONTACTS_LIMIT = 50; +export const MAX_CONTACTS_LIMIT = 100; + +export interface ContactsOptions { + limit?: string; + /** `next` cursor from a previous page. */ + after?: string; + json?: boolean; + apiUrl?: string; +} + +export type ContactRow = Contact & { self: boolean }; + +export function parseContactsLimit(raw: string | undefined): number { + if (raw === undefined || raw === '') { + return DEFAULT_CONTACTS_LIMIT; + } + + const limit = Number(raw); + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CONTACTS_LIMIT) { + throw new Error(`--limit must be a whole number between 1 and ${MAX_CONTACTS_LIMIT}.`); + } + + return limit; +} + +/** Marks the operator's own row so agents don't page you as a third party. */ +export function markSelf(contacts: Contact[], selfSubscriberId: string | undefined): ContactRow[] { + return contacts.map((contact) => ({ + ...contact, + self: selfSubscriberId !== undefined && contact.id === selfSubscriberId, + })); +} + +export function displayName(contact: Contact): string { + return [contact.firstName, contact.lastName].filter(Boolean).join(' '); +} + +export function renderContactsTable(rows: ContactRow[], next: string | null): string { + if (rows.length === 0) { + return 'No contacts found. Run `human setup` or `human invite --via ` to add people.\n'; + } + + const idWidth = Math.max(...rows.map((row) => row.id.length), 'ID'.length); + const nameWidth = Math.max(...rows.map((row) => displayName(row).length), 'NAME'.length); + const lines = [pc.dim(`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} EMAIL`)]; + + for (const row of rows) { + const name = displayName(row) || pc.dim('—'); + const email = row.email ?? pc.dim('—'); + const self = row.self ? pc.cyan(' (you)') : ''; + lines.push(`${row.id.padEnd(idWidth)} ${name.padEnd(nameWidth)} ${email}${self}`); + } + + if (next) { + lines.push(pc.dim(`More contacts — next page: human contacts --after ${next}`)); + } + + return `${lines.join('\n')}\n`; +} + +export async function contactsCommand(options: ContactsOptions): Promise { + let output: string; + try { + output = await renderContacts(options); + } catch (err) { + return handleError(err); + } + + process.stdout.write(output); + process.exit(0); +} + +async function renderContacts(options: ContactsOptions): Promise { + const limit = parseContactsLimit(options.limit); + const { client, config } = clientFromConfig(options.apiUrl); + const after = options.after?.trim() || undefined; + const page = await listContacts(client, { limit, ...(after ? { after } : {}) }); + const rows = markSelf(page.data, config.subscriberId); + + if (options.json) { + return `${JSON.stringify({ data: rows, next: page.next }, null, 2)}\n`; + } + + return renderContactsTable(rows, page.next); +} diff --git a/packages/human/src/commands/invite.spec.ts b/packages/human/src/commands/invite.spec.ts index 78ffca778b1..d53a3e33bd6 100644 --- a/packages/human/src/commands/invite.spec.ts +++ b/packages/human/src/commands/invite.spec.ts @@ -40,7 +40,7 @@ vi.mock('./interact', async (importOriginal) => { }; }); -const { parseInviteHumanId, resolveInviteVia, runInvite } = await import('./invite'); +const { parseInviteHumanId, resolveInviteVia, runInvite, splitName } = await import('./invite'); const operatorConfig: HumanCliConfig = { apiUrl: 'https://api.novu.co', @@ -65,6 +65,19 @@ describe('parseInviteHumanId', () => { }); }); +describe('splitName', () => { + it('splits on the first space and collapses whitespace', () => { + expect(splitName('Alice Chen')).toEqual({ firstName: 'Alice', lastName: 'Chen' }); + expect(splitName(' Mary Ann Smith ')).toEqual({ firstName: 'Mary', lastName: 'Ann Smith' }); + expect(splitName('Alice')).toEqual({ firstName: 'Alice' }); + }); + + it('returns undefined for blank input so no name is cleared', () => { + expect(splitName(undefined)).toBeUndefined(); + expect(splitName(' ')).toBeUndefined(); + }); +}); + describe('resolveInviteVia', () => { it('uses --via when provided', () => { expect(resolveInviteVia([slackLink(), telegramLink()], 'Slack')).toBe('slack'); @@ -152,4 +165,45 @@ describe('runInvite', () => { expect(result.via).toBe('telegram'); expect(hasChannelEndpoint).toHaveBeenCalledWith(expect.anything(), 'tg-1', 'bob'); }); + + it('forwards --name to the relay setup for chat channels', async () => { + listAgentIntegrations.mockResolvedValue([telegramLink()]); + hasChannelEndpoint.mockResolvedValue(true); + + await runInvite('alice', { via: 'telegram', name: 'Alice Chen' }); + + expect(setupHumanRelay).toHaveBeenCalledWith(expect.anything(), { + subscriberId: 'alice', + agentIdentifier: 'human-relay', + firstName: 'Alice', + lastName: 'Chen', + }); + }); + + it('forwards --name alongside --email and labels an already-linked email human', async () => { + listAgentIntegrations.mockResolvedValue([ + { integration: { identifier: 'email-1', providerId: 'novu-email-agent', active: true } }, + ]); + getSubscriberEmail.mockResolvedValue(undefined); + + await runInvite('carol', { via: 'email', email: 'carol@acme.com', name: 'Carol' }); + expect(setupHumanRelay).toHaveBeenCalledWith(expect.anything(), { + subscriberId: 'carol', + agentIdentifier: 'human-relay', + email: 'carol@acme.com', + firstName: 'Carol', + }); + + setupHumanRelay.mockClear(); + getSubscriberEmail.mockResolvedValue('carol@acme.com'); + + const result = await runInvite('carol', { via: 'email', name: 'Carol Diaz' }); + expect(result.alreadyLinked).toBe(true); + expect(setupHumanRelay).toHaveBeenCalledWith(expect.anything(), { + subscriberId: 'carol', + agentIdentifier: 'human-relay', + firstName: 'Carol', + lastName: 'Diaz', + }); + }); }); diff --git a/packages/human/src/commands/invite.ts b/packages/human/src/commands/invite.ts index 52ee2c2c004..df63fec4111 100644 --- a/packages/human/src/commands/invite.ts +++ b/packages/human/src/commands/invite.ts @@ -22,10 +22,31 @@ import { export interface InviteOptions { via?: string; email?: string; + /** Display name, e.g. "Alice Chen" — split into firstName/lastName on the subscriber. */ + name?: string; async?: boolean; apiUrl?: string; } +/** + * `--name "Alice Chen"` → `{ firstName: 'Alice', lastName: 'Chen' }`; a single + * token is just a firstName. Returns undefined for blank input so callers can + * spread it straight into the setup payload without clearing an existing name. + */ +export function splitName(raw: string | undefined): { firstName: string; lastName?: string } | undefined { + const name = raw?.trim().replace(/\s+/g, ' '); + if (!name) { + return undefined; + } + + const spaceAt = name.indexOf(' '); + if (spaceAt === -1) { + return { firstName: name }; + } + + return { firstName: name.slice(0, spaceAt), lastName: name.slice(spaceAt + 1) }; +} + export interface InviteResult { humanId: string; via: HumanChannel; @@ -76,6 +97,7 @@ export async function runInvite(humanIdArg: string, options: InviteOptions): Pro const links = await listAgentIntegrations(client, agentIdentifier); const via = resolveInviteVia(links, options.via); const linked = findLinkedIntegration(links, via); + const name = splitName(options.name); if (!linked) { throw new Error(`No ${via} channel is linked to the relay agent. Run \`human setup ${via}\` first.`); @@ -87,11 +109,11 @@ export async function runInvite(humanIdArg: string, options: InviteOptions): Pro result = await inviteEmail(client, humanId, agentIdentifier, linked.integration.sharedInboundAddress, options); break; case 'telegram': - await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier }); + await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier, ...name }); result = await inviteTelegram(client, humanId, linked.integration.identifier, options); break; case 'slack': - await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier }); + await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier, ...name }); result = await inviteSlack(client, humanId, agentIdentifier, linked.integration.identifier, options); break; default: { @@ -106,10 +128,11 @@ export async function runInvite(humanIdArg: string, options: InviteOptions): Pro export async function inviteCommand(humanIdArg: string, options: InviteOptions): Promise { try { const result = await runInvite(humanIdArg, options); + const who = options.name?.trim() ? `${result.humanId} (${options.name.trim()})` : result.humanId; const lead = options.async && !result.alreadyLinked && result.via !== 'email' ? 'Link issued. After they connect, address them with:' - : `${result.humanId} is ${result.alreadyLinked ? 'already ' : ''}linked on ${result.via}. Address them with:`; + : `${who} is ${result.alreadyLinked ? 'already ' : ''}linked on ${result.via}. Address them with:`; process.stdout.write(`\n${pc.green('✔')} ${lead}\n` + ` ${pc.bold(`human ask "…" --to ${result.humanId}`)}\n`); @@ -193,12 +216,19 @@ async function inviteEmail( if (existingEmail && !options.email) { info(`${humanId} is already linked on email (${existingEmail}).`); + // Still honor a name passed alongside so `invite --name` is a way to label + // someone who was linked before names existed. + const name = splitName(options.name); + if (name) { + await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier, ...name }); + } + return { humanId, via: 'email', alreadyLinked: true }; } const email = options.email ? requireEmail(options.email) : await promptInviteEmail(); - await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier, email }); + await setupHumanRelay(client, { subscriberId: humanId, agentIdentifier, email, ...splitName(options.name) }); if (inboundAddress) { info(`Replies go to ${pc.bold(inboundAddress)} — answering an interaction is just replying to its email.`); diff --git a/packages/human/src/commands/setup.spec.ts b/packages/human/src/commands/setup.spec.ts new file mode 100644 index 00000000000..0d72a1049d6 --- /dev/null +++ b/packages/human/src/commands/setup.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { resolveOperatorName } = await import('./setup'); + +describe('resolveOperatorName', () => { + it('uses --name without prompting', async () => { + const prompt = vi.fn(); + + await expect(resolveOperatorName({ name: 'Dima Grossman' }, false, { isTTY: true, prompt })).resolves.toEqual({ + firstName: 'Dima', + lastName: 'Grossman', + }); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('prompts once on a first-run TTY and accepts an empty answer', async () => { + const prompt = vi.fn().mockResolvedValue('Alice'); + await expect(resolveOperatorName({}, false, { isTTY: true, prompt })).resolves.toEqual({ firstName: 'Alice' }); + expect(prompt).toHaveBeenCalledTimes(1); + + const empty = vi.fn().mockResolvedValue(' '); + await expect(resolveOperatorName({}, false, { isTTY: true, prompt: empty })).resolves.toBeUndefined(); + }); + + it('never prompts when already set up or when stdin is not a TTY', async () => { + const prompt = vi.fn(); + + await expect(resolveOperatorName({}, true, { isTTY: true, prompt })).resolves.toBeUndefined(); + await expect(resolveOperatorName({}, false, { isTTY: false, prompt })).resolves.toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/human/src/commands/setup.ts b/packages/human/src/commands/setup.ts index 9dbb15557b8..60eb2f2223b 100644 --- a/packages/human/src/commands/setup.ts +++ b/packages/human/src/commands/setup.ts @@ -33,6 +33,7 @@ import { pollUntil, sleep } from '../poll'; import { renderQR } from '../qr'; import { installHumanSkill, resolveSkillHosts } from '../skills/install-skills'; import { handleError } from './interact'; +import { splitName } from './invite'; import { CHANNEL_POLL_INTERVAL_MS, CHANNEL_POLL_TIMEOUT_MS, @@ -47,12 +48,38 @@ import { const BOTFATHER_URL = 'https://t.me/botfather'; +/** + * `--name` always wins. Otherwise ask once — only on the very first setup + * (no subscriberId in config yet) and only on a TTY; an empty answer or a + * non-interactive run just leaves the name unset. + */ +export async function resolveOperatorName( + options: Pick, + alreadySetUp: boolean, + io: { isTTY: boolean; prompt: (question: string) => Promise } = { + isTTY: Boolean(process.stdin.isTTY), + prompt: promptLine, + } +): Promise<{ firstName: string; lastName?: string } | undefined> { + if (options.name !== undefined) { + return splitName(options.name); + } + + if (alreadySetUp || !io.isTTY) { + return undefined; + } + + return splitName(await io.prompt('Your name (shown to agents, optional): ')); +} + interface SetupOptions { apiUrl?: string; secretKey?: string; telegramBotToken?: string; slackConfigToken?: string; email?: string; + /** Your display name; skips the first-run prompt. */ + name?: string; agentIdentifier?: string; /** Tri-state: undefined = ask (TTY) / skip (non-TTY); true/false = explicit `--skill`/`--no-skill`. */ skill?: boolean; @@ -87,9 +114,10 @@ export async function setupCommand(channelArg: string | undefined, options: Setu // 2. Provision the relay agent + the human's subscriber row. const subscriberId = existing?.subscriberId ?? `human_${randomBytes(6).toString('hex')}`; const relayIdentifier = options.agentIdentifier ?? existing?.relayAgentIdentifier ?? DEFAULT_RELAY_AGENT_IDENTIFIER; + const name = await resolveOperatorName(options, Boolean(existing?.subscriberId)); info('Setting up your human relay...'); - const relay = await setupHumanRelay(client, { subscriberId, agentIdentifier: relayIdentifier }); + const relay = await setupHumanRelay(client, { subscriberId, agentIdentifier: relayIdentifier, ...name }); // 3. Channel linking — linked channels live on the server; locally we only // remember a default preference for when the caller does not pass `--via`. @@ -121,7 +149,7 @@ export async function setupCommand(channelArg: string | undefined, options: Setu // 5. Smoke test on the channel that was just linked. await createInteraction(client, { kind: 'tell', - prompt: 'You\'re connected. Agents can now reach you here — try `human approve "Deploy to production?"`.', + prompt: `${name ? `Hi ${name.firstName}, you're` : "You're"} connected. Agents can now reach you here — try \`human approve "Deploy to production?"\`.`, to: subscriberId, via: channel, agentIdentifier: relay.agentIdentifier, diff --git a/packages/human/src/index.ts b/packages/human/src/index.ts index 3bee3cb8e54..0741af6180a 100644 --- a/packages/human/src/index.ts +++ b/packages/human/src/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { version } from '../package.json'; import { channelsCommand } from './commands/channels'; +import { contactsCommand } from './commands/contacts'; import { runInteraction } from './commands/interact'; import { inviteCommand } from './commands/invite'; import { cancelCommand, listCommand } from './commands/list'; @@ -111,6 +112,7 @@ program .option('--telegram-bot-token ', 'BotFather token (skips the interactive prompt)') .option('--slack-config-token ', 'Slack App Configuration Token (skips the interactive prompt)') .option('--email
', 'your email address for the email channel (skips the interactive prompt)') + .option('--name ', 'your name, shown to agents (skips the first-run prompt)') .option('--agent-identifier ', 'relay agent identifier (default: human-relay)') .option('--skill', 'also install the human-cli skill for coding agents (default: prompt on a TTY)') .option('--no-skill', 'skip the coding-agent skill install') @@ -125,11 +127,21 @@ program 'channel to link them on (telegram, slack, email). Required when several channels are linked.' ) .option('--email
', 'their email address (required for --via email when not a TTY)') + .option('--name ', 'their display name, e.g. "Alice Chen" (shown in `human contacts`)') .option('--async', 'print the connect URL and exit instead of waiting for them to finish') .option('--api-url ', 'Novu API URL override') .description('Link another human to a channel (sends them a Slack/Telegram connect URL)') .action(inviteCommand); +program + .command('contacts') + .option('--limit ', 'max contacts per page (default: 50, max: 100)') + .option('--after ', 'continue from the `next` cursor of a previous page') + .option('--json', 'print JSON ({ data, next }; rows carry `self: true` for you; pass `next` to --after)') + .option('--api-url ', 'Novu API URL override') + .description('List humans (subscribers) agents can reach with --to') + .action(contactsCommand); + program .command('channels') .option('--default ', 'switch the default channel') diff --git a/packages/human/src/skills/content/human-cli/SKILL.md b/packages/human/src/skills/content/human-cli/SKILL.md index 9ad1a19cd1e..c49ec6a2a8a 100644 --- a/packages/human/src/skills/content/human-cli/SKILL.md +++ b/packages/human/src/skills/content/human-cli/SKILL.md @@ -65,14 +65,44 @@ To reach a *different* person than the one who ran setup, they need a linked channel too: ```bash -human invite alice --via slack -human invite bob --via telegram --async -human invite carol --via email --email carol@acme.com +human invite alice --via slack --name "Alice Chen" +human invite bob --via telegram --async --name "Bob" +human invite carol --via email --email carol@acme.com --name "Carol Diaz" ``` Send them the printed URL (Slack authorize or Telegram Start). `--async` prints the URL and returns immediately. This does **not** change `~/.novu/human.json`. After they connect, address them with `--to alice`. +`--name` is what `human contacts` shows next to the id, so always pass it +when you know who the person is. + +## Who can I reach: check contacts before coordinating between people + +When a task involves more than the one human who ran setup — routing a +question to the right owner, getting a second approval, telling someone +else a job finished — look before you ask: + +```bash +human contacts --json +``` + +Each row is a subscriber the environment knows about: `id` (the subscriberId), +`firstName`/`lastName`, `email`, `phone`, free-form `data`, and `self: true` +on the person who ran setup (the default `--to` when you pass nothing). +Pages are 50 rows by default; when `next` is non-null, fetch the rest with +`human contacts --after ` before concluding someone isn't there. +Pick by name or id and pass the `id` to `--to`: + +```bash +human approve "Ship the pricing change?" --to alice +human tell "Deploy is done." --to alice,bob +``` + +Contacts is a directory, not a reachability guarantee. If delivery fails with +"no linked endpoint", that person exists but hasn't connected the +channel yet — run `human invite --via --name "…"`, send them +the URL, and retry. Never invent an id that isn't in the list, and +never page `self` as if they were a third party. ## The four commands @@ -137,9 +167,9 @@ other useful work to do while you wait. is asking. Set this whenever you have a stable identity (skip it for one-off ad hoc runs). - `--to ` / `--via ` — `--to` addresses humans - who are already linked. Link someone else first with - `human invite alice --via slack` (prints a connect URL for them; does not - change your local identity). `--to alice,bob` lets any listed human settle + who are already linked. Find them with `human contacts --json` first; link + someone new with `human invite alice --via slack --name "Alice Chen"` + (prints a connect URL for them; does not change your local identity). `--to alice,bob` lets any listed human settle (first valid answer wins, max 50). `--via` on ask/approve is only a delivery override when that person has several channels; don't guess — if you need a specific one, pass `--via`. If they have no endpoint yet, the API tells diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index d20e8701a37..a5b224b1485 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -34,7 +34,6 @@ export type { AgentEventEnvelope, AgentFilePart, AgentHashFields, - AgentMcpConnectionAction, AgentMcpConnectionPart, AgentMcpConnectionPartState, AgentMessage, @@ -46,7 +45,6 @@ export type { AgentTextPart, AgentTextPartState, AgentThinkingPart, - AgentToolApprovalAction, AgentToolApprovalDecision, AgentToolDefinition, AgentToolPart, @@ -70,6 +68,7 @@ export type { WebChatToolsDefinition, } from './web-chat'; export { WebChatPlanLimitError, type WebChatPlanLimitReason } from './web-chat/web-chat-plan-limit-error'; +export { pendingActionKey } from './web-chat/derive-pending-actions'; /** * Load Web Chat on a {@link Novu} instance. Safe to call more than one time. diff --git a/packages/js/src/web-chat/agent-conversation-runtime.ts b/packages/js/src/web-chat/agent-conversation-runtime.ts index b49c98bf24a..89d2a1b3040 100644 --- a/packages/js/src/web-chat/agent-conversation-runtime.ts +++ b/packages/js/src/web-chat/agent-conversation-runtime.ts @@ -315,9 +315,9 @@ export class AgentConversationRuntime { return response; } - /** Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. */ + /** Resolve a pending `approval`. Pass `approvalId` from `pendingActions` or message parts. */ async respondToAction(args: { - actionId: string; + approvalId: string; decision: AgentToolApprovalDecision; }): Promise<{ data?: { conversationId: string }; error?: NovuError | WebChatPlanLimitError }> { const response = await this.#webChat.respondToAction({ @@ -325,7 +325,7 @@ export class AgentConversationRuntime { agentHash: this.#agentHash, key: this.key, conversationId: this.#conversationId, - actionId: args.actionId, + approvalId: args.approvalId, decision: args.decision, }); diff --git a/packages/js/src/web-chat/agent-message.types.ts b/packages/js/src/web-chat/agent-message.types.ts index 60a255476dd..815ae4c4371 100644 --- a/packages/js/src/web-chat/agent-message.types.ts +++ b/packages/js/src/web-chat/agent-message.types.ts @@ -66,9 +66,9 @@ export type AgentApprovalPart = { input?: Record; source?: AgentToolSource; state: AgentApprovalPartState; - /** Server-generated. Pass this id to `respondToAction`. Do not create it on the client. */ + /** Server-generated approve action id. Mapped internally when `decision` is `'approved'`. */ approveActionId?: string; - /** Server-generated. Pass this id to `respondToAction`. Do not create it on the client. */ + /** Server-generated deny action id. Mapped internally when `decision` is `'denied'`. */ denyActionId?: string; /** Server-generated always-allow-this-tool action id. Present for managed tools that support trust. */ trustToolActionId?: string; @@ -88,19 +88,10 @@ export type AgentMcpConnectionPart = { message?: string; }; -/** Pending tool-approval item. Pass `id` to `respondToAction`. */ -export type AgentToolApprovalAction = Omit & { - type: 'tool-approval'; - id: string; -}; - -/** Pending MCP connect item. Open `authorizeUrl`. */ -export type AgentMcpConnectionAction = Omit & { - id: string; -}; - -/** One item the UI must handle: a tool approval or an MCP connect card. */ -export type AgentPendingAction = AgentToolApprovalAction | AgentMcpConnectionAction; +/** One pending item the UI must handle. Same shape as the message part. */ +export type AgentPendingAction = + | (AgentApprovalPart & { state: 'pending' }) + | (AgentMcpConnectionPart & { state: 'pending' }); /** Citation. */ export type AgentSourcePart = { diff --git a/packages/js/src/web-chat/derive-pending-actions.ts b/packages/js/src/web-chat/derive-pending-actions.ts index 1e0e1ad9946..9e34ee95379 100644 --- a/packages/js/src/web-chat/derive-pending-actions.ts +++ b/packages/js/src/web-chat/derive-pending-actions.ts @@ -1,25 +1,21 @@ import type { AgentMessage, AgentPendingAction } from './agent-message.types'; -/** Pending tool-approval and MCP-connect actions in `messages`. */ +/** Stable dedup and lookup key for a pending action derived from message parts. */ +export function pendingActionKey(action: AgentPendingAction): string { + return action.type === 'approval' ? action.approvalId : action.actionId; +} + +/** Pending approval and MCP-connect parts in `messages`. */ export function derivePendingActions(messages: AgentMessage[]): AgentPendingAction[] { const pending: AgentPendingAction[] = []; for (const message of messages) { for (const part of message.parts) { if (part.type === 'approval' && part.state === 'pending') { - const { state: _state, ...action } = part; - pending.push({ - ...action, - type: 'tool-approval', - id: part.approvalId, - }); + pending.push({ ...part, state: 'pending' }); } if (part.type === 'mcp-connection' && part.state === 'pending') { - const { state: _state, message: _message, ...action } = part; - pending.push({ - ...action, - id: part.actionId, - }); + pending.push({ ...part, state: 'pending' }); } } } diff --git a/packages/js/src/web-chat/index.ts b/packages/js/src/web-chat/index.ts index 33637cb3236..6ffebbd9b6e 100644 --- a/packages/js/src/web-chat/index.ts +++ b/packages/js/src/web-chat/index.ts @@ -9,7 +9,6 @@ export type { AgentConversationTyping, AgentDataPart, AgentFilePart, - AgentMcpConnectionAction, AgentMcpConnectionPart, AgentMcpConnectionPartState, AgentMessage, @@ -21,7 +20,6 @@ export type { AgentTextPart, AgentTextPartState, AgentThinkingPart, - AgentToolApprovalAction, AgentToolApprovalDecision, AgentToolPart, AgentToolPartState, @@ -49,6 +47,7 @@ export type { WebChatPagination, WebChatPaginationStatus, } from './types'; +export { derivePendingActions, pendingActionKey } from './derive-pending-actions'; export { WebChat } from './web-chat'; export type { AgentToolDefinition, diff --git a/packages/js/src/web-chat/types.ts b/packages/js/src/web-chat/types.ts index 516e2f28650..16f00e9c6d5 100644 --- a/packages/js/src/web-chat/types.ts +++ b/packages/js/src/web-chat/types.ts @@ -2,11 +2,9 @@ import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; import type { AgentConversationStatus, AgentConversationTyping, - AgentMcpConnectionAction, AgentMcpConnectionPart, AgentMessage, AgentPendingAction, - AgentToolApprovalAction, AgentToolApprovalDecision, } from './agent-message.types'; @@ -14,11 +12,9 @@ export type { AgentConversationStatus, AgentConversationTyping, AgentEventEnvelope, - AgentMcpConnectionAction, AgentMcpConnectionPart, AgentMessage, AgentPendingAction, - AgentToolApprovalAction, AgentToolApprovalDecision, }; @@ -95,7 +91,7 @@ export type { export type RespondToActionArgs = AgentHashFields & { agentId: string; - actionId: string; + approvalId: string; decision: AgentToolApprovalDecision; conversationId?: string; /** @internal Session key for the local cache. */ diff --git a/packages/js/src/web-chat/web-chat-store.ts b/packages/js/src/web-chat/web-chat-store.ts index 490a4c2afdc..ca90c9a79a8 100644 --- a/packages/js/src/web-chat/web-chat-store.ts +++ b/packages/js/src/web-chat/web-chat-store.ts @@ -4,8 +4,8 @@ import { type AgentConversationState, type AgentMessage, createInitialAgentConversationState, - derivePendingActions, } from './agent-message.types'; +import { derivePendingActions, pendingActionKey } from './derive-pending-actions'; import { appendUserMessage, applyEnvelope, applyEnvelopes } from './apply-envelope'; import { mintClientId } from './idempotency'; import type { WebChatChange, WebChatChangeSource, WebChatPaginationStatus, FetchMoreResult } from './types'; @@ -103,9 +103,11 @@ export class WebChatStore { * Only this store can tell live, history, and local updates apart. */ #publish(entry: ConversationEntry, source: WebChatChangeSource, addedMessages: AgentMessage[]): void { - const newActions = derivePendingActions(entry.messages).filter((action) => !entry.reportedActionIds.has(action.id)); + const newActions = derivePendingActions(entry.messages).filter( + (action) => !entry.reportedActionIds.has(pendingActionKey(action)) + ); for (const action of newActions) { - entry.reportedActionIds.add(action.id); + entry.reportedActionIds.add(pendingActionKey(action)); } this.#onUpdate(entry, { ...source, addedMessages, newActions }); @@ -117,7 +119,7 @@ export class WebChatStore { */ #suppressActions(entry: ConversationEntry, messages: AgentMessage[]): void { for (const action of derivePendingActions(messages)) { - entry.reportedActionIds.add(action.id); + entry.reportedActionIds.add(pendingActionKey(action)); } } diff --git a/packages/js/src/web-chat/web-chat.test.ts b/packages/js/src/web-chat/web-chat.test.ts index 346ef9c50d9..4e9e9d663f1 100644 --- a/packages/js/src/web-chat/web-chat.test.ts +++ b/packages/js/src/web-chat/web-chat.test.ts @@ -4,6 +4,7 @@ import { NovuEventEmitter } from '../event-emitter'; import { NovuError } from '../utils/errors'; import { WebChat } from './web-chat'; import { derivePendingActions } from './agent-message.types'; +import { pendingActionKey } from './derive-pending-actions'; import { createActionIdempotencyKeyForScope } from './idempotency'; import type { WebChatChange } from './types'; @@ -2079,12 +2080,12 @@ describe('WebChat', () => { expect(derivePendingActions(snapshot?.messages ?? [])).toEqual([ { - type: 'tool-approval', - id: 'approval_000001', + type: 'approval', approvalId: 'approval_000001', toolUseId: 'tu_0000001', toolName: 'deleteOrder', input: { orderId: '123' }, + state: 'pending', approveActionId: 'tool-approval:approve:approval_000001', denyActionId: 'tool-approval:deny:approval_000001', }, @@ -2105,7 +2106,7 @@ describe('WebChat', () => { const result = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); @@ -2123,7 +2124,7 @@ describe('WebChat', () => { agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', }); - expect(derivePendingActions(snapshot?.messages ?? [])[0]?.type).toBe('tool-approval'); + expect(derivePendingActions(snapshot?.messages ?? [])[0]?.type).toBe('approval'); }); it('respondToAction POSTs trust-server action id when decision is trust-server', async () => { @@ -2150,7 +2151,7 @@ describe('WebChat', () => { const result = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'trust-server', }); @@ -2179,7 +2180,7 @@ describe('WebChat', () => { await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); @@ -2223,7 +2224,7 @@ describe('WebChat', () => { const result = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_missing', + approvalId: 'approval_missing', decision: 'denied', }); @@ -2250,7 +2251,7 @@ describe('WebChat', () => { const result = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'denied', }); @@ -2499,7 +2500,7 @@ describe('WebChat', () => { await webChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); await webChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); - expect(changes[0]?.newActions.map((action) => action.id)).toEqual(['approval_000001']); + expect(changes[0]?.newActions.map(pendingActionKey)).toEqual(['approval_000001']); expect(changes[1]?.newActions).toEqual([]); }); @@ -2530,7 +2531,7 @@ describe('WebChat', () => { expect(changes[0]?.newActions).toEqual([]); expect(changes[1]?.kind).toBe('live'); expect(changes[1]?.addedMessages).toEqual([]); - expect(changes[1]?.newActions.map((action) => action.id)).toEqual(['approval_000001']); + expect(changes[1]?.newActions.map(pendingActionKey)).toEqual(['approval_000001']); }); it('stays silent for an approval discovered by paging backwards', async () => { @@ -2544,7 +2545,7 @@ describe('WebChat', () => { await webChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); const snapshot = webChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); - expect(derivePendingActions(snapshot?.messages ?? []).map((action) => action.id)).toEqual(['approval_000001']); + expect(derivePendingActions(snapshot?.messages ?? []).map(pendingActionKey)).toEqual(['approval_000001']); const historyChange = changes.find((change) => change.kind === 'history'); expect(historyChange?.newActions).toEqual([]); }); @@ -2897,13 +2898,13 @@ describe('WebChat', () => { await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); @@ -2924,13 +2925,13 @@ describe('WebChat', () => { await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_aaaaaaaaaaaa', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_bbbbbbbbbbbb', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); @@ -2950,13 +2951,13 @@ describe('WebChat', () => { const failed = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); const retried = await webChat.respondToAction({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl', - actionId: 'approval_000001', + approvalId: 'approval_000001', decision: 'approved', }); diff --git a/packages/js/src/web-chat/web-chat.ts b/packages/js/src/web-chat/web-chat.ts index 0ffb0fc62b0..7288e9a3816 100644 --- a/packages/js/src/web-chat/web-chat.ts +++ b/packages/js/src/web-chat/web-chat.ts @@ -6,7 +6,7 @@ import type { Result } from '../types'; import { NovuError } from '../utils/errors'; import type { BaseSocketInterface } from '../ws/base-socket'; import { AgentConversationRuntime } from './agent-conversation-runtime'; -import { type AgentConversationError, type AgentMessage, derivePendingActions } from './agent-message.types'; +import { type AgentApprovalPart, type AgentConversationError, type AgentMessage, derivePendingActions } from './agent-message.types'; import type { ConversationArgs } from './conversation-runtime.types'; import { createActionIdempotencyKeyForScope, createMessageIdempotencyKey } from './idempotency'; import { runtimeCacheKey } from './runtime-cache-key'; @@ -229,8 +229,11 @@ export class WebChat extends BaseModule { 'Cannot respond to action without a conversation id', 'Failed to respond to action', async (entry, conversationId) => { - const pending = derivePendingActions(entry.messages).find((action) => action.id === args.actionId); - if (!pending || pending.type !== 'tool-approval') { + const pending = derivePendingActions(entry.messages).find( + (action): action is AgentApprovalPart & { state: 'pending' } => + action.type === 'approval' && action.approvalId === args.approvalId + ); + if (!pending) { return { error: new NovuError('Pending action not found', new Error('pending action not found')) }; } @@ -249,7 +252,7 @@ export class WebChat extends BaseModule { }; } - const scope = `respond:${conversationId}:${args.actionId}:${args.decision}`; + const scope = `respond:${conversationId}:${args.approvalId}:${args.decision}`; const idempotencyKey = createActionIdempotencyKeyForScope(scope); return this.#webChatService.respondToAction({ diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/pending-action-card.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/pending-action-card.tsx index 4b170e2f893..a3558db14b3 100644 --- a/packages/novu/src/commands/connect/templates/web-chat/ts/pending-action-card.tsx +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/pending-action-card.tsx @@ -206,7 +206,7 @@ export function ToolApprovalCard({ setFailure(undefined); try { - const result = await onRespond({ actionId: part.approvalId, decision }); + const result = await onRespond({ approvalId: part.approvalId, decision }); if (result.error) setFailure(result.error.message); } finally { setBusy(undefined); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index c77e5b6df9f..9f4e1dadf23 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,5 +1,5 @@ export type * from '@novu/js'; -export { PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; +export { pendingActionKey, PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; export { NovuProvider, useNovu } from './NovuProvider'; export * from './useWebChat'; export * from './useChannelConnection'; diff --git a/packages/react/src/hooks/useWebChat.ts b/packages/react/src/hooks/useWebChat.ts index 0499c225aef..966caceec69 100644 --- a/packages/react/src/hooks/useWebChat.ts +++ b/packages/react/src/hooks/useWebChat.ts @@ -113,10 +113,10 @@ export type UseWebChatResult = { error?: NovuError | WebChatPlanLimitError; }>; /** - * Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. + * Resolve a pending `approval`. Pass `approvalId` from `pendingActions` or message parts. * Does not throw. Resolves `{ data, error }`. Inspect `error` on the result, or show hook `error`. */ - respondToAction: (args: { actionId: string; decision: AgentToolApprovalDecision }) => Promise<{ + respondToAction: (args: { approvalId: string; decision: AgentToolApprovalDecision }) => Promise<{ data?: RespondToActionResult; error?: NovuError | WebChatPlanLimitError; }>; @@ -493,7 +493,7 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { [callRuntime] ); const respondToAction = useCallback( - (args: { actionId: string; decision: AgentToolApprovalDecision }) => + (args: { approvalId: string; decision: AgentToolApprovalDecision }) => callRuntime((target) => target.respondToAction(args)), [callRuntime] ); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4f115adc503..5c612718ca0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,5 @@ export type * from '@novu/js'; -export { PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; +export { pendingActionKey, PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; export type { AllLocalization, diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index 596bd4f3b22..bd917e11047 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -181,7 +181,7 @@ export function useSubscriptions(_: UseSubscriptionsProps): UseSubscriptionsResu } export type * from '@novu/js'; -export { PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; +export { pendingActionKey, PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; export type { AllLocalization, diff --git a/playground/web-chat/src/components/assistant-ui/elements/tool-fallback.aui.tsx b/playground/web-chat/src/components/assistant-ui/elements/tool-fallback.aui.tsx index c99eea3b095..f814e451418 100644 --- a/playground/web-chat/src/components/assistant-ui/elements/tool-fallback.aui.tsx +++ b/playground/web-chat/src/components/assistant-ui/elements/tool-fallback.aui.tsx @@ -155,7 +155,7 @@ function ToolFallbackTrigger({ const Icon = isDeniedApproval ? XCircleIcon : statusIconMap[statusType]; const label = isCancelled ? "Cancelled tool" - : isPendingApproval || statusType === "requires-action" + : isPendingApproval ? "Tool approval" : isDeniedApproval ? "Denied tool" @@ -400,14 +400,9 @@ const approvalOptionLabel = (option: ToolApprovalOption) => option.id; const offersInterruptAction = ( - status: ToolCallMessagePartStatus | undefined, approval: ToolCallMessagePart["approval"], interrupt: ToolCallMessagePart["interrupt"], -) => - status?.type !== "requires-action" || - status.reason !== "interrupt" || - approval != null || - interrupt != null; +) => approval != null || interrupt != null; function ToolFallbackApproval({ className, @@ -437,7 +432,7 @@ function ToolFallbackApproval({ ) return null; - if (!offersInterruptAction(status, approval, interrupt)) return null; + if (!offersInterruptAction(approval, interrupt)) return null; // A declared option list is a host constraint: render only what the host // exposes; do not invent actions the runtime cannot execute. @@ -624,14 +619,14 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({ status?.type === "incomplete" && status.reason === "cancelled"; const isRequiresAction = status?.type === "requires-action"; const shouldRenderApproval = - isRequiresAction && offersInterruptAction(status, approval, interrupt); - - const [open, setOpen] = useState(isRequiresAction); - const [prevRequiresAction, setPrevRequiresAction] = - useState(isRequiresAction); - if (isRequiresAction !== prevRequiresAction) { - setPrevRequiresAction(isRequiresAction); - if (isRequiresAction) setOpen(true); + isRequiresAction && offersInterruptAction(approval, interrupt); + + const [open, setOpen] = useState(shouldRenderApproval); + const [prevShouldRenderApproval, setPrevShouldRenderApproval] = + useState(shouldRenderApproval); + if (shouldRenderApproval !== prevShouldRenderApproval) { + setPrevShouldRenderApproval(shouldRenderApproval); + if (shouldRenderApproval) setOpen(true); } return ( diff --git a/playground/web-chat/src/components/assistant-ui/web-chat-runtime.tsx b/playground/web-chat/src/components/assistant-ui/web-chat-runtime.tsx index 68f3bb2e951..bcf3f219a63 100644 --- a/playground/web-chat/src/components/assistant-ui/web-chat-runtime.tsx +++ b/playground/web-chat/src/components/assistant-ui/web-chat-runtime.tsx @@ -61,7 +61,7 @@ export function WebChatRuntimeProvider({ const onRespondToToolApproval = useCallback( async (options: { approvalId: string; approved: boolean; optionId?: string }) => { await chat.respondToAction({ - actionId: options.approvalId, + approvalId: options.approvalId, decision: decisionFromApprovalOption(options.optionId, options.approved), }); }, diff --git a/playground/web-chat/src/components/web-chat.tsx b/playground/web-chat/src/components/web-chat.tsx index 2183ff3d683..4d8807a8200 100644 --- a/playground/web-chat/src/components/web-chat.tsx +++ b/playground/web-chat/src/components/web-chat.tsx @@ -119,7 +119,7 @@ export function WebChat({ conversationId: activeConversationId, isRunning, conversationStatus, - pendingApprovalCount: pendingActions.filter((action) => action.type === 'tool-approval').length, + pendingApprovalCount: pendingActions.filter((action) => action.type === 'approval').length, runOrigin: runOrigin(isRunning, lastTransition), lastRunTransition: lastTransition, }; diff --git a/playground/web-chat/src/lib/agent-message-to-thread-message.ts b/playground/web-chat/src/lib/agent-message-to-thread-message.ts index eb3625af41b..21f9487e343 100644 --- a/playground/web-chat/src/lib/agent-message-to-thread-message.ts +++ b/playground/web-chat/src/lib/agent-message-to-thread-message.ts @@ -97,6 +97,9 @@ export function agentMessageToThreadMessage(message: AgentMessage): ThreadMessag const isStreaming = message.parts.some( (part) => (part.type === 'text' || part.type === 'thinking') && part.state === 'streaming' ); + const hasPendingApproval = message.parts.some( + (part) => part.type === 'approval' && part.state === 'pending' + ); for (const part of message.parts) { switch (part.type) { @@ -177,11 +180,6 @@ export function agentMessageToThreadMessage(message: AgentMessage): ThreadMessag } } - const hasPendingApproval = message.parts.some( - (part): part is Extract => - part.type === 'approval' && part.state === 'pending' - ); - if (message.role === 'user') { // Stable assistant-ui identity across optimistic opt_* → server msg_* reconciliation. const threadMessageId = message.idempotencyKey ?? message.id; diff --git a/playground/web-chat/src/lib/approval-alert.ts b/playground/web-chat/src/lib/approval-alert.ts index 1230db6ee75..335f0627313 100644 --- a/playground/web-chat/src/lib/approval-alert.ts +++ b/playground/web-chat/src/lib/approval-alert.ts @@ -1,6 +1,6 @@ 'use client'; -import type { AgentPendingAction } from '@novu/react'; +import { pendingActionKey, type AgentPendingAction } from '@novu/react'; import { useCallback, useEffect, useRef } from 'react'; import { emitDebugEvent } from './debug-events'; @@ -34,11 +34,12 @@ export function useApprovalAlert(): (action: AgentPendingAction) => void { }, []); return useCallback((action: AgentPendingAction) => { - const label = action.type === 'tool-approval' ? action.toolName : action.displayName; + const label = action.type === 'approval' ? action.toolName : action.displayName; + const key = pendingActionKey(action); emitDebugEvent({ source: 'sdk', name: `onActionRequested ${label}`, - payload: { actionId: action.id, type: action.type }, + payload: { actionId: key, type: action.type }, }); if (document.visibilityState === 'visible') return; @@ -50,7 +51,7 @@ export function useApprovalAlert(): (action: AgentPendingAction) => void { if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { new Notification('Action needed', { body: label, - tag: action.id, + tag: key, }); } }, []);