From 0c78ea89fd5d5ba83c87265ed1019e1a04e089ad Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 07:19:38 +0100 Subject: [PATCH 01/13] feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop --- .changeset/hosted-webhook-ingress.md | 14 + docs/ai-chat/backend.mdx | 31 + docs/ai-chat/reference.mdx | 40 + docs/docs.json | 13 + docs/webhooks/channels.mdx | 133 ++++ docs/webhooks/connect.mdx | 35 + docs/webhooks/deliveries.mdx | 48 ++ docs/webhooks/filters.mdx | 99 +++ docs/webhooks/human-in-the-loop.mdx | 143 ++++ docs/webhooks/overview.mdx | 96 +++ docs/webhooks/session-routing.mdx | 96 +++ docs/webhooks/sources.mdx | 115 +++ packages/cli-v3/src/dev/devSupervisor.ts | 13 +- .../src/entryPoints/dev-index-worker.ts | 2 + .../src/entryPoints/managed-index-worker.ts | 2 + packages/slack/package.json | 76 ++ packages/slack/src/index.test.ts | 307 +++++++ packages/slack/src/index.ts | 409 ++++++++++ packages/slack/tsconfig.json | 8 + packages/slack/tsconfig.src.json | 12 + packages/slack/vitest.config.ts | 8 + packages/trigger-sdk/src/v3/ai.ts | 747 +++++++++++++++++- .../src/v3/channelReactions.test.ts | 39 + packages/trigger-sdk/src/v3/chat.ts | 18 + packages/trigger-sdk/src/v3/webhooks.ts | 325 +++++++- pnpm-lock.yaml | 123 ++- 26 files changed, 2940 insertions(+), 12 deletions(-) create mode 100644 .changeset/hosted-webhook-ingress.md create mode 100644 docs/webhooks/channels.mdx create mode 100644 docs/webhooks/connect.mdx create mode 100644 docs/webhooks/deliveries.mdx create mode 100644 docs/webhooks/filters.mdx create mode 100644 docs/webhooks/human-in-the-loop.mdx create mode 100644 docs/webhooks/overview.mdx create mode 100644 docs/webhooks/session-routing.mdx create mode 100644 docs/webhooks/sources.mdx create mode 100644 packages/slack/package.json create mode 100644 packages/slack/src/index.test.ts create mode 100644 packages/slack/src/index.ts create mode 100644 packages/slack/tsconfig.json create mode 100644 packages/slack/tsconfig.src.json create mode 100644 packages/slack/vitest.config.ts create mode 100644 packages/trigger-sdk/src/v3/channelReactions.test.ts diff --git a/.changeset/hosted-webhook-ingress.md b/.changeset/hosted-webhook-ingress.md new file mode 100644 index 00000000000..82e5f06fd5b --- /dev/null +++ b/.changeset/hosted-webhook-ingress.md @@ -0,0 +1,14 @@ +--- +"@trigger.dev/core": minor +"@trigger.dev/sdk": minor +"@trigger.dev/slack": minor +"trigger.dev": minor +--- + +Add hosted webhooks: receive and verify provider webhooks as a task, with no ingress or verification code of your own. + +- `webhook()` declares an endpoint that routes a verified, typed event to an `onEvent` handler. Choose a source with a preset (`webhooks.stripe()`, `webhooks.github()`, and others) or `webhooks.custom(config)`. Declared webhooks are discovered like tasks and synced to a hosted URL on deploy. +- `filter` gates which deliveries run, using a type-safe expression checked against the event at author time (`event.`/`header.`/`webhook.` paths, `&&`/`||`, comparison and `in`/`contains` operators, field-to-field comparison, and array quantifiers). A non-matching delivery is still recorded, not routed. +- `chat.event({ source, key, type })` routes deliveries that share a `key` to one durable session (per customer, installation, or issue) and delivers them to an agent's `onAction` as a typed envelope. +- Channels turn a chat surface into an agent frontend: `chat.channels.custom({ source, key, inbound, send })`, or the new `@trigger.dev/slack` package's `slack()` (Slack Events API verification, per-thread sessions, `chat.postMessage`/`chat.update` egress, `mentions()`, `startOn`, lifecycle reactions). Inbound messages run as turns and the reply posts back. Human-in-the-loop is built in: a tool with no `execute` pauses the turn, the connector posts controls (Slack ships Approve / Deny buttons), and a verified click resolves the tool and resumes the run. +- HTTP API for listing webhook endpoints and deliveries, plus rotate-secret, enable/disable, and replay. diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index c055570ef16..4aa8fdb14d4 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -470,6 +470,37 @@ Custom actions let the frontend send structured commands (undo, rollback, edit, See [Actions](/ai-chat/actions). +### Webhook events and channels + +Two `chat.agent()` options wire an agent to verified inbound webhooks. `events` claims [`chat.event(...)`](/webhooks/session-routing) descriptors: each verified delivery is routed to this agent's session and arrives at `onAction` as an action (not a turn), so [session routing](/webhooks/session-routing) decides which conversation it lands on. `channels` claims channel connectors that turn an external chat surface into a frontend for the agent: an inbound message runs as a turn through `run()` and the reply is posted back. `slack()` ships in `@trigger.dev/slack`, and `chat.channels.custom(...)` builds a connector for any source without a preset. + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", +}); + +export const myChat = chat.agent({ + id: "my-chat", + events: [orderEvents], + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + onAction: async ({ action }) => { + // A verified order-events delivery arrives here as an action. + }, + run: async (payload) => { + // Inbound Slack messages run here as normal turns. + }, +}); +``` + +See [session routing](/webhooks/session-routing) and [channels](/webhooks/channels). For the interactive approvals layer, where a turn pauses on a human decision (buttons in the thread) and resumes on the click, see [human-in-the-loop](/webhooks/human-in-the-loop). + ### Chat history Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools. diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index da08f9a0473..fa5f6201691 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -47,6 +47,8 @@ Options for `chat.agent()`. | `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise` | — | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) | | `actionSchema` | `TaskSchema` | — | Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) | | `onAction` | `(event: ActionEvent) => Promise \| unknown` | — | Handle custom actions. Actions are not turns — only `hydrateMessages` + `onAction` fire. Return a `StreamTextResult` (or `string` / `UIMessage`) for a model response; return `void` for side-effect-only. See [Actions](/ai-chat/actions) | +| `events` | `ChatEvent[]` | — | Webhook event descriptors (from `chat.event()`) whose verified deliveries are routed to this agent as actions and handled in `onAction`. See [session routing](/webhooks/session-routing). | +| `channels` | `ChannelConnector[]` | — | Channel connectors (for example `slack()`) that turn an external chat surface into a frontend for the agent: inbound messages run as turns and the reply posts back. See [channels](/webhooks/channels). | | `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | | `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | @@ -501,6 +503,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | Method | Description | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `chat.agent(options)` | Create a chat agent | +| `chat.event(options)` | Declare an inbound webhook event descriptor an agent claims via `chat.agent({ events })`. See [session routing](/webhooks/session-routing). | +| `chat.channels.custom(options)` | Create a generic chat-frontend channel over any verified webhook source (you supply the egress). The `slack()` preset ships in `@trigger.dev/slack`. See [channels](/webhooks/channels). | | `chat.createSession(payload, options)` | Create an async iterator for chat turns | | `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | | `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` | @@ -530,6 +534,42 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.withUIMessage(config?)` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/ai-chat/types) | | `chat.withClientData({ schema })` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed client data schema. See [Types](/ai-chat/types#typed-client-data-with-chatwithclientdata) | +## `chat.event` + +Declare an inbound webhook event that an agent claims via [`events`](#chatagentoptions) on `chat.agent()`. It is a descriptor only, with no handler: it names a [source](/webhooks/sources) to verify, a `key` template that resolves each delivery to a durable [session](/ai-chat/sessions), and an optional `type` label (defaults to the descriptor `id`). Verified deliveries are routed to that session and arrive at `onAction` as a `{ type, event, source, headers, deliveryId }` envelope, not as a chat turn. See [session routing](/webhooks/session-routing). + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", +}); +``` + +## `chat.channels.custom` + +Create a generic chat-frontend channel over any verified [source](/webhooks/sources), claimed via [`channels`](#chatagentoptions) on `chat.agent()`. You supply the session `key`, the `inbound` map from event to turn message, and your own `send` egress that posts the reply back, so the whole round-trip is under your control. Inbound messages run as normal turns and the reply is posted back. The `slack()` preset ships in `@trigger.dev/slack` and wires the egress for you. See [channels](/webhooks/channels), and the interactive approvals layer at [human-in-the-loop](/webhooks/human-in-the-loop). + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const mySurface = chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (event) => event.text, + send: async (message, ctx) => { + const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef); + return { ref }; + }, +}); +``` + ## `chat.withUIMessage` Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`. diff --git a/docs/docs.json b/docs/docs.json index 609ff7b3e16..076a2989367 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -147,6 +147,19 @@ } ] }, + { + "group": "Webhooks", + "pages": [ + "webhooks/overview", + "webhooks/sources", + "webhooks/connect", + "webhooks/deliveries", + "webhooks/filters", + "webhooks/session-routing", + "webhooks/channels", + "webhooks/human-in-the-loop" + ] + }, { "group": "Configuration", "pages": [ diff --git a/docs/webhooks/channels.mdx b/docs/webhooks/channels.mdx new file mode 100644 index 00000000000..eb1d36ff499 --- /dev/null +++ b/docs/webhooks/channels.mdx @@ -0,0 +1,133 @@ +--- +title: "Channels (chat frontends)" +description: "Point Slack (or any chat surface) at an agent: messages become turns and replies post back." +sidebarTitle: "Channels" +--- + +A [session route](/webhooks/session-routing) delivers a verified event to an agent as an [action](/ai-chat/actions): the agent reacts, and the response is a side effect. A **channel** is the other half: the webhook IS the chat surface. Inbound messages become **turns** (the normal `run()` loop), and the agent's reply is posted **back** to the surface. A Slack thread becomes a real conversation with the agent, exactly like the browser chat, just a different frontend. + +List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) `events`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; + +export const supportAgent = chat.agent({ + id: "support-agent", + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + run: async ({ messages }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages }), +}); +``` + +The `run()` loop is unchanged: the agent does not know or care that it is talking to Slack. One verified Slack message in a thread is routed to a durable [session](/ai-chat/sessions) keyed to that thread, run as a turn, and the reply is posted into the thread. + +## Slack + +`slack()` (from `@trigger.dev/slack`) is a channel connector: it verifies inbound Slack events, maps a message to the turn, and posts the reply back with `chat.postMessage` / `chat.update`. + + + + Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` bot scope and install it to your workspace to get a bot token (`xoxb-...`). + + + Deploying registers a hosted [endpoint](/webhooks/connect) for the channel. Set its signing secret to your Slack app's **Signing Secret**, and pass the bot token as `token`. + + + In the app's **Event Subscriptions**, set the request URL to the endpoint's webhook URL. Slack sends a one-time `url_verification` handshake, which the endpoint answers automatically. Subscribe the bot to `message.channels`, then invite the bot to the channel (`/invite @yourapp`). + + + +By default `slack()` keys one session per thread, strips the leading bot mention from the message, posts an "on it..." placeholder while the agent works, and edits it to the answer. Override any of that: + +```ts +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + // ignore anything but questions (composed with the built-in self-message guard) + filter: "event.event.text contains '?'", + inbound: (e) => e.event?.text ?? "", + outbound: (reply) => ({ text: reply.text }), + ack: (e) => ({ text: "thinking..." }), // pass `null` to post only the final answer +}); +``` + + + `slack()` always drops the bot's own messages (and their edits) before they reach the agent, so the + agent never replies to itself. A multi-workspace app can pass a `token` resolver keyed on the event's + team instead of a single string. + + +### Summoning with a mention + +By default `slack()` starts (or resumes) a session for every non-bot message in a subscribed channel. To make the agent respond only when it is @mentioned, pass `startOn` with the `mentions` helper. The first mention in a thread starts the session, and the agent then follows the rest of the thread without needing to be mentioned again. + +```ts +import { slack, mentions } from "@trigger.dev/slack"; + +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + startOn: mentions("U012BOT"), // your bot's user id (pass several for multiple bots) +}); +``` + +### Reacting to messages + +`slack()` can add an emoji reaction to the triggering message to signal progress. Set `reactions` with any of `working`, `done`, and `error`: the connector adds `working` when the turn starts, swaps it to `done` when the turn finishes, and reacts with `error` if it fails. This needs the `reactions:write` scope. + +```ts +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + reactions: { working: "eyes", done: "white_check_mark", error: "warning" }, +}); +``` + +### Options + +| Option | Type | Description | +| --- | --- | --- | +| `id` | `string` | Connector id, unique per agent. | +| `token` | `string` or resolver | Bot token (`xoxb-...`), or a function of the event's team for multi-workspace apps. | +| `key` | `string` | Session [key](/webhooks/session-routing) template. Defaults to one session per thread. | +| `filter` | `string` | Extra [filter](/webhooks/filters), composed with the built-in self-message guard. | +| `startOn` | `string` | Only start a session when the event matches (see `mentions`). Existing sessions always resume. | +| `ack` | message, `null`, or function | Placeholder posted while the agent works. Pass `null` to post only the final answer. | +| `reactions` | `{ working?, done?, error? }` | Lifecycle emoji reactions on the triggering message. | +| `inbound` / `outbound` | functions | Map the Slack event to the turn, and the reply to a Slack message. | +| `delivery` | `"final"` or `"stream"` | `"final"` (default) posts a placeholder and edits it to the answer. `"stream"` edits live as the reply streams. | +| `apiBaseUrl` | `string` | Override the Slack Web API base, for testing against a mock. | + +## Approvals and interactive controls + +An agent on a channel can pause a turn to get a human decision, approving a refund or confirming a deletion, and resume once someone clicks a button in the thread. `slack()` renders Approve / Deny buttons for you and collapses them to the decision once clicked. See [human-in-the-loop](/webhooks/human-in-the-loop). + +## Any surface: `chat.channels.custom` + +For a surface without a preset, `chat.channels.custom` is the generic connector. You supply the [source](/webhooks/sources) to verify, the session `key`, the `inbound` map, and the egress `send`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { webhooks } from "@trigger.dev/sdk"; + +const mySurface = chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (e) => e.text, + outbound: (reply) => (reply.text ? { text: reply.text } : null), // null posts nothing + send: async (message, ctx) => { + const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef); + return { ref }; // an existing ref means edit-in-place on the next turn + }, +}); +``` + +`send` is called to post the reply. `ctx.previousRef` is the ref you returned last time, so streaming or a follow-up edits the same message instead of posting a new one. Return `null` from `outbound` to stay silent (a tool-only turn, say). + +## Channels vs events + +Both are inbound surfaces on a `chat.agent`, and an agent can list both: + +- [`events`](/webhooks/session-routing) (`chat.event`): the webhook is a signal. Delivered to `onAction`; the agent acts, no reply is sent back. +- `channels` (`slack`, `chat.channels.custom`): the webhook is a chat frontend. Delivered as a turn to `run()`; the reply is posted back. diff --git a/docs/webhooks/connect.mdx b/docs/webhooks/connect.mdx new file mode 100644 index 00000000000..0f6430e117a --- /dev/null +++ b/docs/webhooks/connect.mdx @@ -0,0 +1,35 @@ +--- +title: "Connecting a provider" +description: "Point a provider at the webhook URL and set the signing secret." +sidebarTitle: "Connecting a provider" +--- + +When you deploy (or run `dev`), each webhook task gets an **endpoint** with a unique, unguessable webhook URL. Open the webhook in the dashboard, go to **Endpoints**, and open the endpoint to find its **Connect** panel. + + + + Copy it from the endpoint's Connect panel. On Trigger.dev Cloud it looks like + `https://webhooks.trigger.dev/webhooks/v1/ingest/`. A self-hosted instance serves it from that + instance's own base URL. This is what you give the provider as its webhook destination. + + + A webhook can't accept deliveries until its signing secret is set. Until then every request is + rejected. There are two flows, and the Connect panel shows the right one for the provider: + + - **The provider generates the secret** (Stripe, Svix): copy it from the provider and paste it + into **Set secret**. + - **You choose the secret** (GitHub, or a service you control): click **Generate secret** and + Trigger.dev mints a strong secret and shows it once. Paste that into the provider's webhook config. + + + Add the webhook URL as the destination in your provider's dashboard. The Connect panel + shows the exact signature scheme (header, algorithm, signing string) the provider should use. + + + + + The signing secret is stored encrypted and is never shown again after it's set. To rotate it, + use **Rotate secret** (or **Regenerate**) and update the provider with the new value. + + +Once a provider is sending events, watch them arrive on the [Deliveries](/webhooks/deliveries) page, which also explains what an [endpoint](/webhooks/deliveries#endpoints) is. diff --git a/docs/webhooks/deliveries.mdx b/docs/webhooks/deliveries.mdx new file mode 100644 index 00000000000..95fcbed6519 --- /dev/null +++ b/docs/webhooks/deliveries.mdx @@ -0,0 +1,48 @@ +--- +title: "Deliveries and endpoints" +description: "Observe inbound webhook requests, the runs they trigger, and their payloads in the dashboard." +sidebarTitle: "Deliveries & endpoints" +--- + +The dashboard surfaces two concepts under the **Webhooks** section. + +## Deliveries + +A delivery is a single inbound request that passed verification. The **Deliveries** page lists every +delivery across all your webhooks (much like the Runs page), and you can filter by webhook, status, +delivery id, or run id. + +Open a delivery to see: + +- Its **status** and the **run** it triggered (linked). +- The verified **event payload** and the inbound **request headers**, on separate tabs. +- The external delivery id, idempotency key, and timestamps. + + + Duplicate deliveries are deduplicated automatically. The idempotency key is the provider's event id + (e.g. the Stripe event id, or GitHub's `X-GitHub-Delivery`), so a provider retry of the same event + resolves to the original delivery and won't trigger a second run. + + +## Endpoints + +An endpoint is the connection instance for a webhook: its webhook URL, signing-secret state, +verification scheme, and delivery history. Each webhook's **Endpoints** tab lists its endpoints (a +declared webhook has one), and opening an endpoint shows its [Connect panel](/webhooks/connect) and +its scoped deliveries. + +## What happens to a request + + + + The signature, timestamp, and idempotency key are checked. A failure returns `400` and records + nothing. + + + A verified request becomes a delivery, with its parsed event and headers stored. + + + The delivery is routed to your webhook task, which runs and calls `onEvent`. The delivery's status + reflects that run's outcome. + + diff --git a/docs/webhooks/filters.mdx b/docs/webhooks/filters.mdx new file mode 100644 index 00000000000..4ab7f7f8483 --- /dev/null +++ b/docs/webhooks/filters.mdx @@ -0,0 +1,99 @@ +--- +title: "Filtering deliveries" +description: "Gate which verified webhook deliveries run, with a type-safe filter checked against the event." +sidebarTitle: "Filters" +--- + +By default every verified delivery runs your `onEvent` (or routes to a [session](/webhooks/session-routing)). A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything. + +Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing. + +## Adding a filter + +Pass a `filter` string to `webhook()`. It is a small expression checked, at build time, against the event shape from your [source](/webhooks/sources#typing-the-event): + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onOrder = webhook({ + id: "orders", + source: webhooks.stripe(), + // only route succeeded payment intents over $100 + filter: "event.type == 'payment_intent.succeeded' && event.data.object.amount >= 10000", + onEvent: async ({ event }) => { + // only runs for deliveries that matched + }, +}); +``` + +The filter is type-safe: referencing a field that does not exist, or comparing it to the wrong kind of literal, is a compile error, not a runtime surprise. + +## What a non-match does + +A delivery that does not match is **not dropped**. It still returns `200` to the provider and is recorded as a [delivery](/webhooks/deliveries) with the status `FILTERED` and a reason naming the clause that failed (and the value it saw). It just never triggers a run. This keeps a filtered delivery auditable: you can see in the dashboard that it arrived and why it was not routed. + + + If a filter throws while evaluating (for example, a malformed event), the delivery is routed rather + than dropped. Filters fail open so a filter bug never silently swallows real events. + + +## The expression language + +A filter is one or more `path operator value` clauses combined with `&&` and `||` (use parentheses to group). + +### Paths + +A path reads from one of three namespaces: + +- `event.*`: the verified, parsed request body, for example `event.data.object.amount`. +- `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`. +- `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`). + +The [session routing](/webhooks/session-routing) key template addresses this same parsed body, but spells it `{body.*}` rather than `event.*`. + +### Operators + +| Operator | Meaning | +| --- | --- | +| `==` `!=` | equality | +| `>` `<` `>=` `<=` | numeric comparison | +| `in` `not in` | membership in a list, for example `event.type in ['a','b']` | +| `startsWith` `endsWith` `contains` | string matching | + +Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or a list for `in` / `not in`. + +### Comparing two fields + +The right-hand side can be another path instead of a literal, so you can compare two fields of the same event: + +```ts +filter: "event.billing.country == event.shipping.country"; +``` + +### Matching inside a list + +`any` and `all` quantify over an array, testing a sub-path on each element: + +```ts +// route only if at least one line item has a positive quantity +filter: "event.items any ( quantity > 0 )"; +``` + +### Spacing + +The type checker reads the filter as a token stream, so a couple of spots are strict about spacing: keep `in` / `not in` lists unspaced (`['a','b']`, not `[ 'a', 'b' ]`) and put spaces around the quantifier parentheses (`any ( ... )`). + +To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too): + +```ts +export const onGithub = webhook({ + id: "github", + source: webhooks.github(), + filter: "header.x-github-event in ['issues','pull_request']", + onEvent: async ({ event }) => {}, +}); +``` + +## Filtering a session route + +A `filter` works the same way on [`chat.event`](/webhooks/session-routing): a non-matching delivery is recorded `FILTERED` and never reaches the session. diff --git a/docs/webhooks/human-in-the-loop.mdx b/docs/webhooks/human-in-the-loop.mdx new file mode 100644 index 00000000000..1fc52cbce61 --- /dev/null +++ b/docs/webhooks/human-in-the-loop.mdx @@ -0,0 +1,143 @@ +--- +title: "Human-in-the-loop" +sidebarTitle: "Human-in-the-loop" +description: "Pause an agent mid-turn to get a human decision from the channel (Slack approve/deny), then resume with the answer." +--- + +**An agent on a [channel](/webhooks/channels) can pause a turn to get a human decision, then resume once someone answers in the thread.** It posts controls into the conversation and picks up where it left off once someone clicks, with the decision merged into the turn. This is the channel counterpart of browser [human-in-the-loop](/ai-chat/patterns/human-in-the-loop): the same no-`execute` tool, but the controls live in Slack (or your own surface) instead of a React component. The [`slack()`](/webhooks/channels) connector ships Approve / Deny buttons out of the box. + +## How it works + +The building block is a tool with no `execute` function. When the model calls it, the turn completes with the tool call still pending instead of resolving it. Over a channel the framework then takes over the round-trip: + +```mermaid +sequenceDiagram + participant U as User + participant S as Slack + participant A as Agent run + U->>S: Message in a thread + S->>A: Verified delivery starts a turn + A->>A: Model calls requestApproval (no execute) + A->>S: renderInteraction posts Approve / Deny + Note over A: Turn completes, run suspends (no compute while waiting) + U->>S: Clicks a button + S->>A: Signed block_actions callback to the same webhook URL + A->>A: onInteraction resolves the pending tool + A->>S: finalizeInteraction collapses the buttons + A->>A: Run resumes, model continues + A->>S: Reply posts back to the thread +``` + +Because it is a no-`execute` pause, the run suspends while it waits rather than holding compute. A human can take minutes or days to decide without burning compute or hitting [`maxDuration`](/runs/max-duration). The mechanics are the same as the browser case, covered in [human-in-the-loop](/ai-chat/patterns/human-in-the-loop#duration-and-cost-while-paused). + +## Slack approvals + +Out of the box, `slack()` posts Approve / Deny buttons for any pending no-`execute` tool. A click resolves to `{ approved: boolean }` and the buttons collapse to the decision. You define the tool and list the channel on the agent: + +```ts trigger/support-agent.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; +import { streamText, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +// No execute: calling this pauses the turn for a human decision. +const requestApproval = tool({ + description: + "Request human approval before a sensitive or irreversible action. " + + "Call this and stop; you will receive { approved: boolean }, then proceed or decline.", + inputSchema: z.object({ + action: z.string().describe("A short description of the action needing approval"), + }), +}); + +export const supportAgent = chat.agent({ + id: "support-agent", + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + tools: { requestApproval }, + run: async ({ messages, tools }) => + streamText({ + model: anthropic("claude-sonnet-4-5"), + messages, + tools, + system: + "Before any sensitive or irreversible action (refunds, cancellations, deletions), " + + "call requestApproval and wait. If approved, confirm it is done; if denied, decline.", + }), +}); +``` + +Button clicks arrive on a different Slack API than messages, so there is one extra setup step beyond [connecting the channel](/webhooks/channels): + + + + In the Slack app's **Interactivity & Shortcuts**, enable interactivity and set the Request URL to the **same** webhook URL you used for events. Slack posts button clicks there as a signed `block_actions` callback, which the endpoint verifies and routes to the paused run. + + + Posting and editing the controls uses the `chat:write` scope you already added for replies. No extra scope is needed to collapse the buttons after a decision. + + + +When the model calls `requestApproval`, the bot posts "Approval needed" with the action and Approve / Deny buttons. Clicking **Approve** resolves the tool to `{ approved: true }`, the buttons collapse to "Approved by @you", and the agent continues and posts the outcome. **Deny** resolves `{ approved: false }`. + +## Customizing the controls + +For [`chat.channels.custom`](/webhooks/channels), or to override the Slack defaults, three hooks own the interaction round-trip: + + + Map the pending tool call(s) to the controls you post. `pending` is a list of `{ toolCallId, toolName, input }`. Return `null` to skip posting controls. + + + + Map a verified callback event to a resolution. The `output` is stitched onto the pending tool call matched by `toolCallId` and the run resumes. Return `null` to treat the event as a normal message instead (a new turn). + + + + Collapse the controls after a decision so they cannot be clicked again. Best effort: a failure here is logged and the run still resumes. + + +Slack encodes the decision in each button's `value` as `${toolCallId}::approve|deny` so `onInteraction` can resolve the exact call, and `finalizeInteraction` posts to the interaction's `response_url` with `replace_original` to swap the buttons for the outcome. On a custom surface you choose the encoding and how you edit the controls away. + +```ts +chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (e) => e.text, + send: async (message, ctx) => ({ ref: await postToMySurface(ctx.event, message) }), + renderInteraction: (pending) => ({ + text: `Approve: ${pending[0].input.action}?`, + buttons: pending.map((c) => [`${c.toolCallId}:yes`, `${c.toolCallId}:no`]), + }), + onInteraction: (e) => { + const [toolCallId, choice] = e.buttonValue.split(":"); + return { toolCallId, output: { approved: choice === "yes" } }; + }, + finalizeInteraction: async (e) => { + await editMySurface(e.messageRef, "Decision recorded"); + }, +}); +``` + +The resolved `output` is whatever your tool expects to receive. The Slack default resolves `{ approved: boolean }`; if your tool needs a richer answer, supply your own `onInteraction` that returns the shape your tool reads. + +## The reply after a decision + +When the turn resumes, the channel reply shows the agent's answer, not the text it produced before pausing. The framework posts the assistant text generated after the tool call, so a preamble like "I'll need approval first" is not concatenated onto the final "done" message. The approval prompt itself already lives in the collapsed controls. + +## Next steps + + + + Point Slack or any surface at an agent so messages become turns. + + + The same no-execute pause, rendered in a React frontend. + + + The durable per-conversation state a channel turn runs on. + + + Gate which verified deliveries reach the agent. + + diff --git a/docs/webhooks/overview.mdx b/docs/webhooks/overview.mdx new file mode 100644 index 00000000000..73dc3b35447 --- /dev/null +++ b/docs/webhooks/overview.mdx @@ -0,0 +1,96 @@ +--- +title: "Webhooks overview" +description: "Receive and verify webhooks from external providers as a task, with a hosted webhook URL." +sidebarTitle: "Overview" +--- + +A webhook is a task that runs when an external provider (Stripe, GitHub, Svix, your own service, …) sends an HTTP request. Trigger.dev gives each webhook a hosted webhook URL, verifies the incoming request's signature, and routes the verified event to your task's `onEvent` handler. + +You don't host an endpoint yourself, and you don't write verification code: you declare which provider the webhook is from, point the provider at the webhook URL, and set the signing secret. + +## Defining a webhook task + +A webhook is created with `webhook()`. It takes an `id`, a `source` (which provider, and how to verify it), and an `onEvent` handler: + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onStripeEvent = webhook({ + id: "stripe-events", + source: webhooks.stripe(), + onEvent: async ({ event, headers, ctx }) => { + // `event` is the verified, parsed body + console.log("Received", event.type, event.id); + + // `headers` is a standard Web Headers object + console.log(headers.get("stripe-signature")); + + // `ctx` is the usual run context + console.log(ctx.run.id); + }, +}); +``` + +`onEvent` receives: + +- **`event`**: the verified request body, parsed from JSON and typed by the source (see [Typing the event](/webhooks/sources#typing-the-event)). +- **`headers`**: the inbound request headers as a Web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object (case-insensitive `.get()` / `.has()`). +- **`ctx`**: the run context, the same one regular tasks receive. + + + A webhook is a first-class task kind. It runs on a real run (with retries, logs, and everything else + tasks get), and shows up in the dashboard alongside your other tasks. + + +## How it works + + + + Define a `webhook()` with a `source`. The source is a provider preset (like `webhooks.stripe()`) + or a `webhooks.custom()` config. See [Sources and verification](/webhooks/sources). + + + Deploying the webhook creates an endpoint with a hosted webhook URL. Set its signing secret and + point your provider at the URL. See [Connecting a provider](/webhooks/connect). + + + Each inbound request is verified, recorded as a delivery, and routed to a run that calls your + `onEvent`. See [Deliveries and endpoints](/webhooks/deliveries). + + + +## Beyond fan-out + +A few things build on the basic model: + +- **[Filters](/webhooks/filters)** gate which deliveries run. A non-matching delivery is recorded but never triggers a run. +- **[Session routing](/webhooks/session-routing)** sends deliveries that share a key to one durable session (per customer, installation, or issue) instead of a fresh run each time. +- **[Channels](/webhooks/channels)** turn a webhook into a chat frontend: inbound messages become agent turns, and the agent's reply posts back to the surface. +- **[Human-in-the-loop](/webhooks/human-in-the-loop)** adds approvals and interactive controls over a channel, like Slack approve and deny buttons. + + + + Provider presets, custom verification, and typing the event. + + + The webhook URL and signing secret. + + + Observe inbound requests in the dashboard. + + + Route only the deliveries you care about. + + + Route deliveries to a durable per-key session. + + + Turn a webhook into a chat frontend for an agent. + + + Approvals and interactive controls over a channel. + + + The other declarative task trigger. + + diff --git a/docs/webhooks/session-routing.mdx b/docs/webhooks/session-routing.mdx new file mode 100644 index 00000000000..575d8914c03 --- /dev/null +++ b/docs/webhooks/session-routing.mdx @@ -0,0 +1,96 @@ +--- +title: "Routing to a session" +description: "Route verified webhook deliveries to a durable per-key session instead of a fresh run." +sidebarTitle: "Session routing" +--- + +A plain [`webhook()`](/webhooks/overview) runs a fresh, stateless run for every delivery. Sometimes you want the opposite: deliveries that share a key (a customer, an installation, an issue) should land on **one durable [session](/ai-chat/sessions)** and be handled in order, with state carried across them. That is what session routing does. + +Instead of a handler, you declare a `chat.event` and list it on an agent. The verified delivery is routed to a find-or-created session and arrives as an [action](/ai-chat/actions). + +## Declaring a chat event + +`chat.event` describes the routing only: the [source](/webhooks/sources) to verify, a `key` that identifies the session, and a `type` label for the delivered action. It has no handler. + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + // one session per customer + key: "{body.data.object.customer}", + type: "order.event", +}); +``` + +### The key + +The `key` is what makes routing durable: two deliveries that resolve to the same key reach the same session; a different key gets its own. It is a template string, evaluated server-side at ingest, whose `{...}` placeholders read three namespaces: + +- `{body.*}`: the parsed body (a bare `{customer}` defaults to the body namespace). +- `{webhook.*}`: endpoint metadata (`externalRef`, `tenantId`, `id`, `source`, `deliveryId`). +- `{header.name}`: an inbound header. + +[Filter](/webhooks/filters) expressions read this same parsed body, but spell it `event.*` rather than `{body.*}`. + +Compose several placeholders into one key: `"{webhook.externalRef}-{body.issue.id}"`. Each placeholder is checked against the event type at build time, so a bad field is a red squiggle on the `key` line, not a runtime surprise. + +### The `type` label + +`type` is a name you choose for the delivered action. It flows straight through to `action.type` on the envelope, so your handler can tell webhook actions apart from each other and from browser actions. It is optional and defaults to the descriptor `id`. + +## Handling deliveries on an agent + +List the descriptor on a [`chat.agent`](/ai-chat/overview) (or a session agent). The delivery arrives at `onAction`, not as a chat message: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { orderEvents } from "./order-events"; + +export const orderAgent = chat.agent({ + id: "order-agent", + events: [orderEvents], + onAction: async ({ action }) => { + switch (action.type) { + case "order.event": + // action.event is the verified Stripe event, fully typed + console.log("order for", action.event.data.object.customer); + break; + } + }, + run: async ({ messages, signal }) => { + /* ... */ + }, +}); +``` + +`action.type` is a closed union of the `type` labels of the events you listed, and each arm narrows `action.event` to that event's payload type. The full envelope is `{ type, event, source, headers, deliveryId }`. + +Because it is an [action](/ai-chat/actions), the handler is not a turn: it can mutate session state and, if you return a `streamText` result, produce a model response. Webhook actions bypass your `actionSchema` (their shape is fixed and already typed). + + + The descriptor never names the agent, and the agent references the descriptor, so there is no + circular dependency. Whichever agent lists it becomes the routing target. + + +## One endpoint per agent + +Listing a descriptor on an agent creates that agent's own endpoint, with its own [webhook URL and signing secret](/webhooks/connect). If two agents list the same descriptor, you get two endpoints, each routing to its own agent, and you point the provider at both URLs. + +An exported `chat.event` that no agent lists routes nothing. `trigger dev` warns about it (it is not an error, since declaring the descriptor before wiring the agent is a normal step), so wire it onto an agent once you are ready. + +## Filtering + +A [`filter`](/webhooks/filters) works here exactly as on a fan-out webhook: a non-matching delivery is recorded `FILTERED` and never reaches the session. + +```ts +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", + filter: "event.type == 'payment_intent.succeeded'", +}); +``` diff --git a/docs/webhooks/sources.mdx b/docs/webhooks/sources.mdx new file mode 100644 index 00000000000..7fa37067de1 --- /dev/null +++ b/docs/webhooks/sources.mdx @@ -0,0 +1,115 @@ +--- +title: "Sources and verification" +description: "Provider presets, custom verification config, and typing the webhook event." +sidebarTitle: "Sources & verification" +--- + +A webhook's `source` tells Trigger.dev which provider the request is from and how to verify it. Use a built-in preset, or `webhooks.custom()` for a provider without one. + +## Presets + +Built-in presets know the provider's signature scheme, so you don't configure anything: + + + +```ts Stripe +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const stripeWebhook = webhook({ + id: "stripe", + source: webhooks.stripe(), + onEvent: async ({ event }) => { + if (event.type === "payment_intent.succeeded") { + // ... + } + }, +}); +``` + +```ts GitHub +export const githubWebhook = webhook({ + id: "github", + source: webhooks.github(), + onEvent: async ({ event, headers }) => { + // GitHub puts the event type in a header + console.log(headers.get("x-github-event")); + }, +}); +``` + +```ts Svix +// Also covers Clerk, Resend, and other Svix-powered providers +export const svixWebhook = webhook({ + id: "svix", + source: webhooks.svix(), + onEvent: async ({ event }) => { + // ... + }, +}); +``` + + + +The available presets are `stripe()`, `github()`, `svix()`, `square()`, and `discord()`. + +## Custom providers + +For a provider without a preset, `webhooks.custom()` describes the scheme as data. For example, an HMAC-SHA256 signature over the raw body, in a custom header: + +```ts +export const customWebhook = webhook({ + id: "custom", + source: webhooks.custom<{ id: string; message: string }>({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-webhook-signature", + signingString: "raw", + idempotencyField: { from: "body", name: "id" }, + }), + onEvent: async ({ event }) => { + console.log(event.message); + }, +}); +``` + + + Reach for a preset first; drop to `custom()` for the long tail. A custom config can almost always + express a provider's scheme without any code. + + +## Typing the event + +Presets ship a sensible default event type, and they're generic, so you can plug in the provider's official type for full type-safety and autocomplete: + +```ts +import type Stripe from "stripe"; +import { webhooks } from "@trigger.dev/sdk"; + +// `event` is now the full, discriminated Stripe.Event union +webhooks.stripe(); +``` + +For `webhooks.custom()`, pass your own event type as `T`. + + + The event is typed but not re-validated against that type at runtime: once the signature is + verified, the body is trusted (the same model the official provider SDKs use). If you want runtime + validation, validate `event` inside `onEvent`. + + +## How verification works + +Every inbound request is verified before your task runs. Trigger.dev checks the signature, the +timestamp (for replay protection, where the provider supplies one), and derives the idempotency key +from the provider's event id. A request that fails verification gets a `400` and never creates a run. + +Presets handle this for you. Under the hood, every scheme is one of: + +- **`hmac`**: HMAC over the raw body or a templated signing string, signature in a header. The header can be a raw value, a prefixed one (like GitHub's `sha256=…`), or a structured one (like Stripe's `t=…,v1=…`). +- **`shared-secret`**: a static token compared in a header, bearer, basic auth, or the body. +- **`url-secret`**: a secret in the URL path or query string. +- **`asymmetric`**: public-key signatures (Ed25519, ECDSA, RSA). You store the provider's public key instead of a shared secret. + +Once a request is verified, see [Connecting a provider](/webhooks/connect) for how to point the +provider at the webhook URL and set the secret. diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 6a0d1888afd..e8ba2d4afaf 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -32,7 +32,7 @@ import type { CliApiClient } from "../apiClient.js"; import { copySkillFolders } from "../build/bundleSkills.js"; import type { DevCommandOptions } from "../commands/dev.js"; import { DevRunController } from "../entryPoints/dev-run-controller.js"; -import { cliLink, prettyError } from "../utilities/cliOutput.js"; +import { cliLink, prettyError, prettyWarning } from "../utilities/cliOutput.js"; import { devBranchPathSegment } from "../utilities/devBranch.js"; import { eventBus } from "../utilities/eventBus.js"; import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; @@ -382,6 +382,16 @@ class DevSupervisor implements WorkerRuntime { return; } + // Non-blocking nudge: a chat.event that no agent lists routes nothing. Common (and fine) + // mid-development while you wire up the chat.agent, so warn rather than fail. + const unclaimedSessionWebhooks = backgroundWorker.manifest.unclaimedSessionWebhooks ?? []; + if (unclaimedSessionWebhooks.length > 0) { + prettyWarning( + `Unclaimed chat.event: ${unclaimedSessionWebhooks.join(", ")}`, + "Not listed on any agent's `events: [...]`, so deliveries won't be routed anywhere. Add each to a chat.agent once you wire it up." + ); + } + const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks); const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = { @@ -391,6 +401,7 @@ class DevSupervisor implements WorkerRuntime { cliPackageVersion: manifest.cliPackageVersion, tasks: backgroundWorker.manifest.tasks, prompts: backgroundWorker.manifest.prompts, + webhooks: backgroundWorker.manifest.webhooks, queues: backgroundWorker.manifest.queues, contentHash: manifest.contentHash, sourceFiles, diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index 59228f0971d..bf83ff8d720 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -195,6 +195,8 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), + unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index f463c4156e5..8b3f2665518 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -191,6 +191,8 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), + unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/slack/package.json b/packages/slack/package.json new file mode 100644 index 00000000000..fa8b483eaa5 --- /dev/null +++ b/packages/slack/package.json @@ -0,0 +1,76 @@ +{ + "name": "@trigger.dev/slack", + "version": "4.5.0-rc.7", + "description": "Slack chat frontend (channel) for trigger.dev agents", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/slack" + }, + "type": "module", + "files": [ + "dist" + ], + "tshy": { + "selfLink": false, + "main": true, + "module": true, + "project": "./tsconfig.src.json", + "exclude": [ + "./src/**/*.test.ts" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "sourceDialects": [ + "@triggerdotdev/source" + ] + }, + "scripts": { + "clean": "rimraf dist .tshy .tshy-build .turbo", + "build": "tshy && pnpm run update-version", + "dev": "tshy --watch", + "typecheck": "tsc --noEmit -p tsconfig.src.json", + "test": "vitest", + "update-version": "tsx ../../scripts/updateVersion.ts", + "check-exports": "attw --pack ." + }, + "dependencies": { + "@trigger.dev/core": "workspace:4.5.0-rc.7" + }, + "peerDependencies": { + "@trigger.dev/sdk": "workspace:^4.5.0-rc.7" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@trigger.dev/sdk": "workspace:4.5.0-rc.7", + "rimraf": "6.0.1", + "tshy": "^3.0.2", + "tsx": "4.17.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@triggerdotdev/source": "./src/index.ts", + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "main": "./dist/commonjs/index.js", + "types": "./dist/commonjs/index.d.ts", + "module": "./dist/esm/index.js" +} diff --git a/packages/slack/src/index.test.ts b/packages/slack/src/index.test.ts new file mode 100644 index 00000000000..2777eac61c9 --- /dev/null +++ b/packages/slack/src/index.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from "vitest"; +import { mentions, slack, toSlackMrkdwn, type SlackMessageEvent } from "./index.js"; + +const messageEvent = ( + over: Partial> = {} +): SlackMessageEvent => ({ + type: "event_callback", + event: { type: "message", channel: "C9", ts: "1699999999.0001", text: "hi", ...over }, +}); + +describe("slack channel", () => { + it("default inbound strips a leading bot mention", () => { + const c = slack({ id: "s1", token: "xoxb-t" }); + expect(c.inbound(messageEvent({ text: "<@U123> hello there" }))).toBe("hello there"); + expect(c.inbound(messageEvent({ text: "plain" }))).toBe("plain"); + }); + + it("composes the self-message guard with a user filter, and always admits interactivity", () => { + const guardOnly = slack({ id: "s2", token: "t" }); + expect(guardOnly.filter).toContain("event.event.type == 'message'"); + expect(guardOnly.filter).toContain("event.event.bot_id == null"); + expect(guardOnly.filter).toContain( + "event.event.subtype in [null, 'file_share', 'thread_broadcast']" + ); + expect(guardOnly.filter).toContain("event.type == 'block_actions'"); + + const withUser = slack({ id: "s3", token: "t", filter: "event.event.channel == 'C1'" }); + expect(withUser.filter).toContain("&& (event.event.channel == 'C1')"); + expect(withUser.filter).toContain("event.type == 'block_actions'"); + }); + + it("keys one session per thread, converging message events and interactivity", () => { + const c = slack({ id: "s4", token: "t" }); + expect(c.key).toBe( + "{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}" + ); + }); + + it("renderInteraction produces Block Kit approve/deny buttons carrying the toolCallId", () => { + const c = slack({ id: "s-hitl", token: "t" }); + const msg = c.renderInteraction?.( + [{ toolCallId: "call-1", toolName: "requestApproval", input: { amount: 50 } }], + { + event: messageEvent(), + deliveryId: "d1", + } + ); + const values = (msg?.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value); + expect(values).toContain("call-1::approve"); + expect(values).toContain("call-1::deny"); + }); + + it("onInteraction resolves a block_actions click to a tool output; ignores messages", () => { + const c = slack({ id: "s-hitl2", token: "t" }); + const approve = c.onInteraction?.({ + type: "block_actions", + actions: [{ value: "call-9::approve" }], + } as never); + expect(approve).toEqual({ toolCallId: "call-9", output: { approved: true } }); + + const deny = c.onInteraction?.({ + type: "block_actions", + actions: [{ value: "call-9::deny" }], + } as never); + expect(deny).toEqual({ toolCallId: "call-9", output: { approved: false } }); + + expect(c.onInteraction?.(messageEvent())).toBeNull(); + }); + + it("finalizeInteraction replaces the controls via response_url, dropping the buttons", async () => { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true }) }; + }) + ); + const c = slack({ id: "s-fin", token: "t" }); + await c.finalizeInteraction?.( + { + type: "block_actions", + user: { id: "U42" }, + response_url: "https://hooks.slack.test/r/1", + actions: [{ value: "call-1::approve" }], + message: { + blocks: [ + { type: "section", text: { type: "mrkdwn", text: "Approval needed" } }, + { type: "actions", elements: [{ type: "button", value: "call-1::approve" }] }, + ], + }, + } as never, + { toolCallId: "call-1", output: { approved: true } } + ); + expect(calls[0]?.url).toBe("https://hooks.slack.test/r/1"); + expect(calls[0]?.body.replace_original).toBe(true); + const types = (calls[0]?.body.blocks as Array<{ type: string }>).map((b) => b.type); + expect(types).not.toContain("actions"); + expect(JSON.stringify(calls[0]?.body.blocks)).toContain("Approved"); + vi.unstubAllGlobals(); + }); + + it("finalizeInteraction is a no-op without a response_url", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const c = slack({ id: "s-fin2", token: "t" }); + await c.finalizeInteraction?.( + { type: "block_actions", actions: [{ value: "x::deny" }] } as never, + { toolCallId: "x", output: { approved: false } } + ); + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("passes startOn through verbatim (not composed with the guard)", () => { + const none = slack({ id: "s5", token: "t" }); + expect(none.startOn).toBeUndefined(); + + const summon = slack({ id: "s6", token: "t", startOn: mentions("U012BOT") }); + expect(summon.startOn).toBe( + "(event.event.text contains '<@U012BOT>' || event.event.text contains '<@U012BOT|')" + ); + }); + + it("default ack varies text on crash recovery", () => { + const c = slack({ id: "s7", token: "t" }); + expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "on it..." }); + expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({ + text: "picking this back up...", + }); + }); + + it("ack: null disables the placeholder", () => { + const c = slack({ id: "s8", token: "t", ack: null }); + expect(c.ack).toBeUndefined(); + }); + + it("a custom ack receives the recovery ctx", () => { + const c = slack({ + id: "s9", + token: "t", + ack: (_e, ctx) => ({ text: ctx.recovered ? "resuming" : "starting" }), + }); + expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "starting" }); + expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({ text: "resuming" }); + }); + + it("toSlackMrkdwn converts common markdown to Slack mrkdwn", () => { + expect(toSlackMrkdwn("**bold**")).toBe("*bold*"); + expect(toSlackMrkdwn("__bold__")).toBe("*bold*"); + expect(toSlackMrkdwn("## Heading")).toBe("*Heading*"); + expect(toSlackMrkdwn("- one\n- two")).toBe("• one\n• two"); + expect(toSlackMrkdwn("[docs](https://trigger.dev)")).toBe(""); + expect(toSlackMrkdwn("~~gone~~")).toBe("~gone~"); + // A real model reply: heading + bold + bullets in one string. + expect(toSlackMrkdwn("## Help\n\nI can do **stuff**:\n- a\n- b")).toBe( + "*Help*\n\nI can do *stuff*:\n• a\n• b" + ); + }); + + it("mentions() builds a mention predicate for one or many bot ids", () => { + expect(mentions("U1")).toBe( + "(event.event.text contains '<@U1>' || event.event.text contains '<@U1|')" + ); + expect(mentions("U1", "U2")).toContain("<@U1>"); + expect(mentions("U1", "U2")).toContain("<@U2|"); + expect(() => mentions()).toThrow(/at least one/); + }); + + it("send posts then edits, threading the ref and using the bot token", async () => { + const calls: Array<{ url: string; body: Record; auth: unknown }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string; headers: Record }) => { + calls.push({ url, body: JSON.parse(init.body), auth: init.headers.authorization }); + return { json: async () => ({ ok: true, ts: "1700000000.0001" }) }; + }) + ); + + const c = slack({ id: "s5", token: "xoxb-secret", apiBaseUrl: "https://mock.slack" }); + const event = messageEvent(); + + const ackRes = await c.send!( + { text: "on it..." }, + { + event, + deliveryId: "d1", + mode: "final", + final: false, + } + ); + expect(ackRes.ref).toBe("1700000000.0001"); + expect(calls[0]?.url).toBe("https://mock.slack/chat.postMessage"); + expect(calls[0]?.auth).toBe("Bearer xoxb-secret"); + expect(calls[0]?.body.channel).toBe("C9"); + expect(calls[0]?.body.thread_ts).toBe("1699999999.0001"); + + await c.send!( + { text: "done" }, + { + event, + deliveryId: "d1", + previousRef: ackRes.ref, + mode: "final", + final: true, + } + ); + expect(calls[1]?.url).toBe("https://mock.slack/chat.update"); + expect(calls[1]?.body.ts).toBe("1700000000.0001"); + expect(calls[1]?.body.text).toBe("done"); + + vi.unstubAllGlobals(); + }); + + it("send targets the thread from a block_actions payload (HITL resume egress)", async () => { + const calls: Array<{ body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + calls.push({ body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true, ts: "1700000000.9" }) }; + }) + ); + const c = slack({ id: "s-resume", token: "t", apiBaseUrl: "https://mock.slack" }); + const interaction = { + type: "block_actions", + team: { id: "T1" }, + container: { + type: "message", + channel_id: "C42", + thread_ts: "1699999999.0001", + message_ts: "1700000000.5", + }, + actions: [{ value: "call-1::approve" }], + }; + await c.send!( + { text: "refund approved and processed" }, + { + event: interaction as never, + deliveryId: "d-resume", + mode: "final", + final: true, + } + ); + expect(calls[0]?.body.channel).toBe("C42"); + expect(calls[0]?.body.thread_ts).toBe("1699999999.0001"); + vi.unstubAllGlobals(); + }); + + it("send throws when the bot is not in the channel", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ json: async () => ({ ok: false, error: "not_in_channel" }) })) + ); + const c = slack({ id: "s6", token: "t", apiBaseUrl: "https://mock.slack" }); + await expect( + c.send!({ text: "x" }, { event: messageEvent(), deliveryId: "d", mode: "final", final: true }) + ).rejects.toThrow(/not_in_channel/); + vi.unstubAllGlobals(); + }); + + it("react adds/removes an emoji on the triggering message (colons stripped)", async () => { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true }) }; + }) + ); + const c = slack({ + id: "s7", + token: "xoxb-secret", + apiBaseUrl: "https://mock.slack", + reactions: { working: "eyes", done: "white_check_mark" }, + }); + expect(c.reactions).toEqual({ working: "eyes", done: "white_check_mark" }); + + await c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" }); + expect(calls[0]?.url).toBe("https://mock.slack/reactions.add"); + expect(calls[0]?.body).toMatchObject({ + channel: "C9", + timestamp: "1699999999.0001", + name: "eyes", + }); + + await c.react!( + { name: ":white_check_mark:", remove: true }, + { event: messageEvent(), deliveryId: "d1" } + ); + expect(calls[1]?.url).toBe("https://mock.slack/reactions.remove"); + expect(calls[1]?.body.name).toBe("white_check_mark"); + vi.unstubAllGlobals(); + }); + + it("react swallows already_reacted (idempotent)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ json: async () => ({ ok: false, error: "already_reacted" }) })) + ); + const c = slack({ id: "s8", token: "t", apiBaseUrl: "https://mock.slack" }); + await expect( + c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" }) + ).resolves.toBeUndefined(); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts new file mode 100644 index 00000000000..a91c0de8e69 --- /dev/null +++ b/packages/slack/src/index.ts @@ -0,0 +1,409 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import type { + ChannelAckCtx, + ChannelConnector, + ChannelInteractionCtx, + ChannelInteractionResolution, + ChannelMessage, + ChannelMessageInput, + ChannelPendingToolCall, + ChannelReaction, + ChannelReactCtx, + ChannelReactions, + ChannelReply, + ChannelSendCtx, +} from "@trigger.dev/sdk/ai"; +import type { + WebhookHandshakeConfig, + WebhookHmacConfig, + WebhookSource, +} from "@trigger.dev/core/v3"; + +// A minimal Slack Events API envelope; pass your own event type for fuller typing. +export type SlackMessageEvent = { + type: string; + event_id?: string; + team_id?: string; + event?: { + type: string; + subtype?: string; + text?: string; + user?: string; + channel?: string; + ts?: string; + thread_ts?: string; + bot_id?: string; + }; +}; + +// Slack signs `X-Slack-Signature: v0=` over `v0:{timestamp}:{body}`; the timestamp rides in +// `X-Slack-Request-Timestamp`. You paste the Slack signing secret as the endpoint's signing secret. +const SLACK_VERIFIER: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-slack-signature", + signature: { fieldSeparator: "=", field: "v0" }, + timestamp: { + source: { from: "header", name: "x-slack-request-timestamp" }, + toleranceSeconds: 300, + }, + signingString: { template: "v0:{timestamp}:{body}" }, + idempotencyField: { from: "body", name: "event_id" }, + formPayload: { field: "payload" }, +}; + +// Slack's Event Subscriptions url_verification handshake: echo the challenge, do not record/route. +const SLACK_HANDSHAKE: WebhookHandshakeConfig = { + matchPath: "type", + matchValue: "url_verification", + respondPath: "challenge", +}; + +/** + * One session per Slack thread, for BOTH message events and block_actions interactivity (a button click + * on the in-thread ack). thread_ts is only on replies, so a thread-STARTING message falls back to ts; + * interactivity carries the same thread via `container.thread_ts` / `container.message_ts`. The `||` + * operator resolves to the first non-empty path, so both surfaces converge on one externalId. + */ +const DEFAULT_KEY = + "{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}"; + +/** + * Mandatory loop guard for MESSAGE events: `bot_id == null` drops the agent's own posts (no reply loop); + * the subtype allowlist keeps real user messages (absent subtype matches `null`) and drops system/edit + * events. Interactivity (block_actions) is a separate surface, admitted via INTERACTIVITY_PASS. + */ +const SELF_MESSAGE_GUARD = + "event.event.type == 'message' && event.event.bot_id == null && event.event.subtype in [null, 'file_share', 'thread_broadcast']"; + +/** Interactivity callbacks (button clicks) always pass the loop guard; onInteraction resolves them. */ +const INTERACTIVITY_PASS = "event.type == 'block_actions'"; + +const SLACK_API_BASE_URL = "https://slack.com/api"; + +export type SlackToken = string | ((event: TEvent) => string | Promise); + +export type SlackChannelOptions = { + id: string; + /** Bot token (xoxb-...). A string for one workspace, or a resolver keyed on the event's team_id. */ + token: SlackToken; + /** Session key template. Defaults to one session per thread. */ + key?: string; + /** Map a Slack event to the turn's message. Defaults to the message text with the bot mention stripped. */ + inbound?: (event: TEvent) => ChannelMessageInput; + /** Map the agent's reply to a Slack message. Defaults to the reply text (null posts nothing). */ + outbound?: (reply: ChannelReply) => ChannelMessage | null; + /** Placeholder posted while the agent works, then edited to the answer. `null` posts only the answer. */ + ack?: ((event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null) | null; + /** Extra server-side filter, composed AND with the mandatory self-message guard. */ + filter?: string; + /** + * Only start a NEW session when an event matches this filter; existing threads always resume. Use it + * to summon the bot on mention, then continue the thread silently, e.g. + * `startOn: "event.event.text contains '<@U012BOT>'"` (your bot's user id). + */ + startOn?: string; + /** "final" (default): ack then one edit. "stream": debounced live edits (fast-follow). */ + delivery?: "final" | "stream"; + /** + * Lifecycle emoji reactions on the user's message (names without colons, e.g. "eyes"): `working` is + * added while the turn runs and swapped to `done`, or `error` on failure. Needs the `reactions:write` + * scope. The agent can also react itself via `run({ channel })`. + */ + reactions?: ChannelReactions; + /** Override the Slack Web API base URL (for testing against a mock). */ + apiBaseUrl?: string; +}; + +/** + * Build a predicate that matches when the bot is @mentioned, for `startOn` (summon-on-mention) or + * `filter`. Pass your bot's user id(s) from the Slack app (they start with `U`); matches both the plain + * `<@U012BOT>` and labelled `<@U012BOT|name>` mention forms: + * `slack({ id, token, startOn: mentions("U012BOT") })`. + */ +export function mentions(...botUserIds: string[]): string { + const ids = botUserIds.filter(Boolean); + if (ids.length === 0) throw new Error("mentions() requires at least one bot user id"); + const clauses = ids.flatMap((id) => [ + `event.event.text contains '<@${id}>'`, + `event.event.text contains '<@${id}|'`, + ]); + return `(${clauses.join(" || ")})`; +} + +/** + * Slack as a chat frontend for an agent. List on `chat.agent({ channels: [slack({...})] })`: verified + * Slack messages in a thread are routed to a durable per-thread session and run as turns, and the reply + * is posted back to the thread. Set the endpoint's signing secret to your Slack signing secret; pass the + * bot token as `token`. Subscribe the app to `message.channels` (and invite the bot to the channel). + */ +export function slack( + options: SlackChannelOptions +): ChannelConnector { + const apiBaseUrl = options.apiBaseUrl ?? SLACK_API_BASE_URL; + const source: WebhookSource = { + provider: "slack", + verifier: { kind: "config", config: SLACK_VERIFIER, handshake: SLACK_HANDSHAKE }, + secretProvisioning: "integrator", + }; + const messageFilter = options.filter + ? `${SELF_MESSAGE_GUARD} && (${options.filter})` + : SELF_MESSAGE_GUARD; + const filter = `${INTERACTIVITY_PASS} || (${messageFilter})`; + const ack = + options.ack === null + ? undefined + : (options.ack ?? + ((_event: TEvent, ctx: ChannelAckCtx) => ({ + text: ctx.recovered ? "picking this back up..." : "on it...", + }))); + + return chat.channels.custom>({ + id: options.id, + source, + key: options.key ?? DEFAULT_KEY, + inbound: options.inbound ?? (defaultSlackInbound as (event: TEvent) => ChannelMessageInput), + outbound: options.outbound ?? defaultSlackOutbound, + ack, + send: makeSlackSend(options.token, apiBaseUrl), + renderInteraction: defaultSlackRenderInteraction as ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx + ) => ChannelMessage | null, + onInteraction: defaultSlackOnInteraction as ( + event: TEvent + ) => ChannelInteractionResolution | null, + finalizeInteraction: defaultSlackFinalizeInteraction as ( + event: TEvent, + resolution: ChannelInteractionResolution + ) => Promise, + // Composed at runtime (guard + optional user filter), so it bypasses the literal-only filter + // validator; the user's `filter` arg was already validated on the way in. + filter: filter as never, + startOn: options.startOn as never, + delivery: options.delivery ?? "final", + react: makeSlackReact(options.token, apiBaseUrl), + reactions: options.reactions, + }); +} + +/** + * Default HITL controls: render each pending human-decision tool as a Block Kit approve/deny pair. The + * button `value` carries `${toolCallId}::${decision}` so `onInteraction` can resolve the exact tool. + */ +function defaultSlackRenderInteraction( + pending: ChannelPendingToolCall[], + _ctx: ChannelInteractionCtx +): ChannelMessage | null { + const call = pending[0]; + if (!call) return null; + const detail = call.input !== undefined ? "\n```" + safeStringify(call.input) + "```" : ""; + return { + text: `Approval needed: ${call.toolName}`, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `*Approval needed* for \`${call.toolName}\`${detail}` }, + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "trigger_hitl_approve", + style: "primary", + text: { type: "plain_text", text: "Approve" }, + value: `${call.toolCallId}::approve`, + }, + { + type: "button", + action_id: "trigger_hitl_deny", + style: "danger", + text: { type: "plain_text", text: "Deny" }, + value: `${call.toolCallId}::deny`, + }, + ], + }, + ], + }; +} + +/** + * Default interaction resolver: a `block_actions` button click resolves the tool named in its `value` + * (`${toolCallId}::approve|deny`) to `{ approved }`. Any non-interactivity event returns null (the + * normal message path handles it). + */ +function defaultSlackOnInteraction(event: unknown): ChannelInteractionResolution | null { + const payload = event as { type?: string; actions?: Array<{ value?: string }> }; + if (payload?.type !== "block_actions") return null; + const action = (payload.actions ?? []).find( + (a) => typeof a?.value === "string" && a.value.includes("::") + ); + if (!action?.value) return null; + const [toolCallId, decision] = action.value.split("::"); + if (!toolCallId || (decision !== "approve" && decision !== "deny")) return null; + return { toolCallId, output: { approved: decision === "approve" } }; +} + +/** + * After a decision, collapse the controls via the interaction's `response_url` (Slack's documented path: + * the click gets a bare 200 ack, then `response_url` accepts `replace_original` for up to 30 minutes). + * Keeps the original context blocks, drops the `actions` block, and appends the outcome, so the buttons + * can't be clicked again. No-op when the payload carries no `response_url` (e.g. a synthetic test event). + */ +async function defaultSlackFinalizeInteraction( + event: unknown, + resolution: ChannelInteractionResolution +): Promise { + const payload = event as { + response_url?: string; + user?: { id?: string }; + message?: { blocks?: unknown[] }; + }; + const responseUrl = payload?.response_url; + if (!responseUrl) return; + + const approved = (resolution.output as { approved?: boolean } | undefined)?.approved === true; + const decision = approved ? "Approved" : "Denied"; + const icon = approved ? ":white_check_mark:" : ":x:"; + const who = payload.user?.id ? ` by <@${payload.user.id}>` : ""; + + const original = Array.isArray(payload.message?.blocks) ? payload.message!.blocks : []; + const kept = original.filter((b) => (b as { type?: string })?.type !== "actions"); + const blocks = [ + ...kept, + { type: "context", elements: [{ type: "mrkdwn", text: `${icon} *${decision}*${who}` }] }, + ]; + + await fetch(responseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ replace_original: true, text: `${decision}${who}`, blocks }), + }); +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +// Strip a leading bot mention (`<@U123> hi` -> `hi`) so the agent sees the plain text. +function defaultSlackInbound(event: SlackMessageEvent): string { + return (event.event?.text ?? "").replace(/^\s*<@[A-Z0-9]+>\s*/i, ""); +} + +/** + * Convert common GitHub-flavored markdown (what a model emits) to Slack mrkdwn: `**bold**` -> `*bold*`, + * `#` headings -> bold, `-`/`*` bullets -> `•`, `[t](url)` -> ``, `~~s~~` -> `~s~`. Applied by the + * default outbound; a custom `outbound` controls its own formatting (call this from it if you want it). + * Note: single-asterisk `*italic*` is left as-is, so it renders bold in Slack (rare in model output). + */ +export function toSlackMrkdwn(md: string): string { + return md + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>") + .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, "*$1*") + .replace(/\*\*([^*\n]+)\*\*/g, "*$1*") + .replace(/__([^_\n]+)__/g, "*$1*") + .replace(/~~([^~\n]+)~~/g, "~$1~") + .replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• "); +} + +function defaultSlackOutbound(reply: ChannelReply): ChannelMessage | null { + return reply.text ? { text: toSlackMrkdwn(reply.text) } : null; +} + +function makeSlackSend(token: SlackToken, apiBaseUrl: string) { + return async ( + message: ChannelMessage, + ctx: ChannelSendCtx + ): Promise<{ ref?: string }> => { + const event = ctx.event as SlackMessageEvent & { + container?: { channel_id?: string; thread_ts?: string; message_ts?: string }; + channel?: { id?: string }; + }; + const channel = event.event?.channel ?? event.container?.channel_id ?? event.channel?.id; + const threadTs = + event.event?.thread_ts ?? + event.event?.ts ?? + event.container?.thread_ts ?? + event.container?.message_ts; + + const resolve = () => (typeof token === "function" ? token(ctx.event) : token); + let botToken = await resolve(); + + const blocks = (message as { blocks?: unknown }).blocks; + const rich = Array.isArray(blocks) && blocks.length > 0 ? { blocks } : {}; + + const post = async () => + ctx.previousRef + ? slackApi(apiBaseUrl, "chat.update", botToken, { + channel, + ts: ctx.previousRef, + text: message.text, + ...rich, + }) + : slackApi(apiBaseUrl, "chat.postMessage", botToken, { + channel, + thread_ts: threadTs, + text: message.text, + ...rich, + }); + + let result = await post(); + // Re-resolve once on an auth error (token rotation) when a resolver was supplied. + if (!result.ok && typeof token === "function" && isAuthError(result.error)) { + botToken = await resolve(); + result = await post(); + } + if (!result.ok) { + // not_in_channel / channel_not_found is the common one: the bot isn't in the channel. Surface it. + throw new Error( + `slack ${ctx.previousRef ? "chat.update" : "chat.postMessage"} failed: ${result.error}` + ); + } + return { ref: ctx.previousRef ?? result.ts }; + }; +} + +function isAuthError(error: string | undefined): boolean { + return error === "invalid_auth" || error === "token_revoked" || error === "account_inactive"; +} + +// Add/remove an emoji reaction on the triggering Slack message (needs the reactions:write scope). +function makeSlackReact(token: SlackToken, apiBaseUrl: string) { + return async (reaction: ChannelReaction, ctx: ChannelReactCtx): Promise => { + const event = ctx.event as SlackMessageEvent; + const channel = event.event?.channel; + const timestamp = event.event?.ts; + const name = reaction.name.replace(/^:|:$/g, ""); + if (!channel || !timestamp || !name) return; + const botToken = typeof token === "function" ? await token(ctx.event) : token; + const method = reaction.remove ? "reactions.remove" : "reactions.add"; + const result = await slackApi(apiBaseUrl, method, botToken, { channel, timestamp, name }); + // already_reacted / no_reaction are benign idempotent outcomes; surface anything else. + if (!result.ok && result.error !== "already_reacted" && result.error !== "no_reaction") { + throw new Error(`slack ${method} failed: ${result.error}`); + } + }; +} + +async function slackApi( + baseUrl: string, + method: string, + token: string, + body: Record +): Promise<{ ok: boolean; ts?: string; error?: string }> { + const res = await fetch(`${baseUrl}/${method}`, { + method: "POST", + headers: { + "content-type": "application/json; charset=utf-8", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + return (await res.json()) as { ok: boolean; ts?: string; error?: string }; +} diff --git a/packages/slack/tsconfig.json b/packages/slack/tsconfig.json new file mode 100644 index 00000000000..16881b51b6e --- /dev/null +++ b/packages/slack/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "references": [ + { + "path": "./tsconfig.src.json" + } + ] +} diff --git a/packages/slack/tsconfig.src.json b/packages/slack/tsconfig.src.json new file mode 100644 index 00000000000..19b058321cb --- /dev/null +++ b/packages/slack/tsconfig.src.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "customConditions": ["@triggerdotdev/source"], + "types": ["node"] + } +} diff --git a/packages/slack/vitest.config.ts b/packages/slack/vitest.config.ts new file mode 100644 index 00000000000..4afd9264256 --- /dev/null +++ b/packages/slack/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + }, +}); diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 844d506079b..5e85d38fb3b 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -36,6 +36,14 @@ import { type TaskWithSchema, TRIGGER_CONTROL_SUBTYPE, type StreamWriteResult, + type AnyChatEvent, + type ChatEventActions, + type ValidatedWebhookKey, + type ValidateWebhookFilter, + type WebhookVerifierArtifact, + type WebhookSecretProvisioning, + type AnyWebhookSource, + type InferWebhookEvent, } from "@trigger.dev/core/v3"; import type { FinishReason, @@ -50,6 +58,7 @@ import type { JSONSchema7, Schema, } from "ai"; +import { chatEvent, normalizeKeyString } from "./webhooks.js"; // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require` // ESM-only `ai@7` (see ../imports/ai-runtime.ts). import { type Attributes, trace } from "@opentelemetry/api"; @@ -171,6 +180,17 @@ const chatSessionHandleKey = locals.create("chat.sessionHandle"); // metadata reads this directly rather than the Session handle id. const chatExternalIdKey = locals.create("chat.externalId"); +/** + * Set at boot when a continuation run inherited an interrupted turn (a + * partial assistant reply on `session.out`). Consumed by the first channel + * turn's ack so the re-emitted placeholder reads as a recovery resume rather + * than a silent duplicate. Boxed so the ack site can flip it to false. + * @internal + */ +const chatChannelRecoveryPendingKey = locals.create<{ value: boolean }>( + "chat.channelRecoveryPending" +); + /** * S2 seq_num of the most recent `turn-complete` control record written by * this worker. Read by `writeTurnCompleteChunk` to know what to trim back @@ -1243,6 +1263,41 @@ function createChatAccessToken( return auth.createTriggerPublicToken(taskId as string, { expirationTime: "24h" }); } +/** + * Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`. + * + * A session is addressed by `externalId`, so the same session is reachable from any surface that + * knows it: a web `useChat` client and a Slack thread converge on one session when they share an + * `externalId` (for a channel, the connector's `key` template produces it). This token carries + * `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out` + * stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`. + * + * Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side + * (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint + * a read+write token instead ({@link createChatStartSessionAction}). + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { chat } from "@trigger.dev/sdk/ai"; + * + * export const watchChat = (externalId: string) => chat.createWatchToken(externalId); + * ``` + */ +function createChatWatchToken( + externalId: string, + options?: { tokenTTL?: string } +): Promise { + if (!externalId) { + throw new Error("chat.createWatchToken: externalId is required (the session addressing key)."); + } + return auth.createPublicToken({ + scopes: { read: { sessions: externalId } }, + expirationTime: options?.tokenTTL ?? "1h", + }); +} + // --------------------------------------------------------------------------- // Chat transport helpers — backend side // --------------------------------------------------------------------------- @@ -1532,6 +1587,11 @@ export type ChatTaskRunPayload< * `tools` option on `chat.agent`). Empty object when no `tools` were declared. */ tools: TTools; + /** + * Present only for a channel-delivered turn whose connector supports reactions. Lets the agent + * react to the triggering message to signal meaning (e.g. `channel.react("white_check_mark")`). + */ + channel?: ChannelRunSurface; }; // Input streams for bidirectional chat communication @@ -4642,12 +4702,434 @@ export type ChatResumeEvent = { + event: TEvent; + deliveryId: string; + previousRef?: string; + mode: "final" | "stream"; + final: boolean; +}; + +export type ChannelAckCtx = { + /** + * True when this ack is being re-posted because the run is recovering an + * interrupted turn after a crash/continuation. The prior run's message ref + * is gone, so egress re-emits into the thread as a fresh message; a + * connector can vary the placeholder text to read as a deliberate resume + * ("picking this back up...") rather than a silent duplicate. + */ + recovered: boolean; +}; + +/** + * A tool call the turn paused on, awaiting a human decision (HITL). `renderInteraction` maps these to + * the controls posted in the thread; the callback resolves one by `toolCallId`. + */ +export type ChannelPendingToolCall = { toolCallId: string; toolName: string; input?: unknown }; + +/** + * A verified interaction callback resolved to a tool output. `onInteraction` returns this to resume the + * paused run: the framework stitches `output` onto the pending tool part (by `toolCallId`) and continues. + */ +export type ChannelInteractionResolution = { toolCallId: string; output: unknown }; + +export type ChannelInteractionCtx = { event: TEvent; deliveryId: string }; + +// A reaction to add (or remove) on the triggering message. `name` is a provider emoji id (e.g. "eyes"). +export type ChannelReaction = { name: string; remove?: boolean }; +export type ChannelReactCtx = { event: TEvent; deliveryId: string }; + +// A lifecycle reaction choice: one emoji name, an array (one picked at random per turn), or a resolver +// of the event returning either (null/undefined to skip). Names are provider emoji ids without colons. +export type ChannelReactionChoice = + | string + | string[] + | (( + event: TEvent + ) => string | string[] | null | undefined | Promise); + +// Lifecycle reactions the run loop applies to the user's message around a turn (add working at start, +// swap to done at complete, error on failure). Any subset; requires the connector's `react`. +export type ChannelReactions = { + working?: ChannelReactionChoice; + done?: ChannelReactionChoice; + error?: ChannelReactionChoice; +}; + +// Handed to run() for a channel-delivered turn (when the connector supports reactions), so the agent can +// react to the triggering message to signal meaning. Best-effort: a failed reaction never throws. +export type ChannelRunSurface = { + react: (name: string) => Promise; + unreact: (name: string) => Promise; +}; + +// Reserved for 2c: resolve a provider credential keyed by the incoming event's installation (team_id), +// not the connector (one connector serves many Slack workspaces). The in-run send() calls it at post time. +export type ResolveChannelToken = (event: TEvent) => Promise; + +declare const channelEventPhantom: unique symbol; + +export interface ChannelConnector { + id: string; + source: string; + key: string; // compiled canonical key template + verifierArtifact: WebhookVerifierArtifact; + secretProvisioning?: WebhookSecretProvisioning; + filter?: string; + // Gate session creation: a resolved key with no session is only started when the event matches this + // filter (existing sessions always resume). Absent => every routed event can start one. + startOn?: string; + inbound: (event: TEvent) => ChannelMessageInput; + // Egress (optional; a channel can be inbound-only). Fires only when `send` is set. + outbound?: (reply: ChannelReply) => ChannelMessage | null; + ack?: (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null; // placeholder posted at turn start ("final" mode) + send?: (message: ChannelMessage, ctx: ChannelSendCtx) => Promise<{ ref?: string }>; + /** HITL: controls posted when a turn pauses on a human-decision tool (a tool with no `execute`). */ + renderInteraction?: ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx + ) => ChannelMessage | null; + /** + * HITL: map a verified callback event to a tool resolution. Non-null resumes the paused run; null + * means treat the event as a normal inbound message (a new turn). + */ + onInteraction?: (event: TEvent) => ChannelInteractionResolution | null; + /** + * HITL: finalize the posted controls once a decision is made (edit them away, show the outcome), so + * the buttons can't be clicked again. Called with the same verified callback event when + * `onInteraction` resolves, before the run resumes. Best-effort: a throw is logged and the run continues. + */ + finalizeInteraction?: ( + event: TEvent, + resolution: ChannelInteractionResolution + ) => Promise | void; + delivery: "final" | "stream"; // "final" (ack + edit) is v1; "stream" (debounced edits) is a fast-follow + // Add/remove a reaction on the triggering message. Powers lifecycle reactions + run()'s channel surface. + react?: (reaction: ChannelReaction, ctx: ChannelReactCtx) => Promise; + reactions?: ChannelReactions; + readonly [channelEventPhantom]?: TEvent; +} +export type AnyChannelConnector = ChannelConnector; + +/** + * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress + * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your + * control. Use for providers without a preset, or to test the channel round-trip end to end. + */ +export function chatChannelCustom< + TSource extends AnyWebhookSource, + const TKey extends string = string, + const TFilter extends string = string, + const TStartOn extends string = string, +>(options: { + id: string; + source: TSource; + key: ValidatedWebhookKey, TKey>; + inbound: (event: InferWebhookEvent) => ChannelMessageInput; + outbound?: (reply: ChannelReply) => ChannelMessage | null; + ack?: (event: InferWebhookEvent, ctx: ChannelAckCtx) => ChannelMessage | null; + send?: ( + message: ChannelMessage, + ctx: ChannelSendCtx> + ) => Promise<{ ref?: string }>; + renderInteraction?: ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx> + ) => ChannelMessage | null; + onInteraction?: (event: InferWebhookEvent) => ChannelInteractionResolution | null; + finalizeInteraction?: ( + event: InferWebhookEvent, + resolution: ChannelInteractionResolution + ) => Promise | void; + react?: ( + reaction: ChannelReaction, + ctx: ChannelReactCtx> + ) => Promise; + reactions?: ChannelReactions>; + filter?: TFilter & ValidateWebhookFilter, TFilter>; + // Only start a new session when the event matches this filter (existing sessions always resume). + startOn?: TStartOn & ValidateWebhookFilter, TStartOn>; + delivery?: "final" | "stream"; +}): ChannelConnector> { + const { + id, + source, + key, + inbound, + outbound, + ack, + send, + renderInteraction, + onInteraction, + finalizeInteraction, + react, + reactions, + filter, + startOn, + delivery, + } = options; + resourceCatalog.registerDeclaredSessionWebhook(id); + return { + id, + source: source.provider, + key: normalizeKeyString(key as string), + verifierArtifact: source.verifier, + secretProvisioning: source.secretProvisioning, + filter, + startOn, + inbound, + outbound, + ack, + send, + renderInteraction, + onInteraction, + finalizeInteraction, + react, + reactions, + delivery: delivery ?? "final", + } as ChannelConnector>; +} + +const chatChannels = { + /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */ + custom: chatChannelCustom, +}; + +// Turn a channel connector's inbound() result into a user UIMessage for the turn. +function toUserUIMessage(input: ChannelMessageInput, messageId: string): UIMessage { + if (typeof input !== "string") return input; + return { id: messageId, role: "user", parts: [{ type: "text", text: input }] } as UIMessage; +} + +/** + * The assistant's closing text for a channel reply: the text after the last tool part in the message, + * so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up") + * is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no + * tool parts (an ordinary turn), so a plain reply is unchanged. + */ +function channelReplyText(message: UIMessage): string { + const parts = (message.parts ?? []) as any[]; + const isTool = (p: any) => { + const t = p?.type; + return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool"); + }; + let lastToolIdx = -1; + parts.forEach((p, i) => { + if (isTool(p)) lastToolIdx = i; + }); + const textFrom = (from: number) => + parts + .slice(from) + .map((p) => + p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : "" + ) + .join(""); + const trailing = textFrom(lastToolIdx + 1); + return trailing.trim().length > 0 ? trailing : textFrom(0); +} + +/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */ +function pendingToolCallsInMessage(message: UIMessage): ChannelPendingToolCall[] { + const out: ChannelPendingToolCall[] = []; + for (const part of (message.parts ?? []) as any[]) { + if (!part || typeof part !== "object") continue; + const type = part.type; + const isTool = + typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool"); + if (!isTool || part.state !== "input-available") continue; + const toolName = + type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length); + if (typeof part.toolCallId === "string") + out.push({ toolCallId: part.toolCallId, toolName, input: part.input }); + } + return out; +} + +/** + * Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`): + * the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with + * the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback). + */ +function buildInteractionResolutionMessage( + resolution: ChannelInteractionResolution, + messages: UIMessage[] +): UIMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== "assistant") continue; + for (const part of (message.parts ?? []) as any[]) { + if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId) continue; + const type = part.type; + const isTool = + typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool"); + if (!isTool) continue; + const slimPart: Record = { + type, + toolCallId: resolution.toolCallId, + state: "output-available", + output: resolution.output, + }; + if (type === "dynamic-tool" && typeof part.toolName === "string") + slimPart.toolName = part.toolName; + return { id: message.id, role: "assistant", parts: [slimPart] } as unknown as UIMessage; + } + } + return undefined; +} + +// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn). +function defaultChannelOutbound(reply: ChannelReply): ChannelMessage | null { + return reply.text ? { text: reply.text } : null; +} + +// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then +// pick one at random from an array. Returns undefined to skip (no choice / empty). +export async function resolveReactionChoice( + choice: ChannelReactionChoice | undefined, + event: unknown +): Promise { + if (choice == null) return undefined; + let value: string | string[] | null | undefined = + typeof choice === "function" ? await choice(event) : choice; + if (Array.isArray(value)) { + if (value.length === 0) return undefined; + value = value[Math.floor(Math.random() * value.length)]; + } + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +// Best-effort reaction on the triggering message; a failure never breaks the turn. +async function applyChannelReaction( + connector: AnyChannelConnector | undefined, + wireEvent: { event: unknown; deliveryId: string } | undefined, + reaction: ChannelReaction +): Promise { + if (!connector?.react || !wireEvent || !reaction.name) return; + try { + await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId }); + } catch (error) { + logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name }); + } +} + +// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no +// `react`, so `payload.channel` is only present when reacting is actually possible. +function buildChannelRunSurface( + connector: AnyChannelConnector | undefined, + wireEvent: { event: unknown; deliveryId: string } | undefined +): ChannelRunSurface | undefined { + if (!connector?.react || !wireEvent) return undefined; + return { + react: (name) => applyChannelReaction(connector, wireEvent, { name }), + unreact: (name) => applyChannelReaction(connector, wireEvent, { name, remove: true }), + }; +} + +// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing +// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is +// the authoritative last write. Best-effort: an edit failure is logged, never fatal. +const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000; +function makeChannelStreamEditor( + connector: ChannelConnector, + channelEvent: { event: unknown; deliveryId: string }, + ackRef: string +) { + const outbound = connector.outbound ?? defaultChannelOutbound; + let latest = ""; + let timer: ReturnType | undefined; + let inFlight = false; + let stopped = false; + + const edit = async () => { + if (stopped || inFlight) return; + const text = latest; + const message = outbound({ + text, + message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage, + final: false, + stopped: false, + }); + if (!message) return; + inFlight = true; + try { + await connector.send!(message, { + event: channelEvent.event as TEvent, + deliveryId: channelEvent.deliveryId, + previousRef: ackRef, + mode: "stream", + final: false, + }); + } catch (error) { + logger.warn("chat.agent: channel stream edit failed", { error }); + } finally { + inFlight = false; + } + }; + + return { + observe(chunk: unknown) { + if (stopped) return; + const c = chunk as { type?: string; delta?: unknown }; + if (c?.type === "text-delta" && typeof c.delta === "string") { + latest += c.delta; + if (!timer) + timer = setTimeout(() => { + timer = undefined; + void edit(); + }, CHANNEL_STREAM_EDIT_INTERVAL_MS); + } + }, + stop() { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} + +// The `action` type onAction receives: the actionSchema output (when set) unioned with the action +// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses +// cleanly when no events are listed; `unknown` is kept only when neither source is present. +type ChatActionType< + TActionSchema extends TaskSchema | undefined, + TW extends readonly AnyChatEvent[], +> = [TActionSchema] extends [TaskSchema] + ? inferSchemaOut | ChatEventActions + : [TW] extends [readonly []] + ? unknown + : ChatEventActions; + export type ChatAgentOptions< TIdentifier extends string, TClientDataSchema extends TaskSchema | undefined = undefined, TUIMessage extends UIMessage = UIMessage, TActionSchema extends TaskSchema | undefined = undefined, TTools extends ToolSet = ToolSet, + TW extends readonly AnyChatEvent[] = [], + TChannels extends readonly AnyChannelConnector[] = [], > = Omit< TaskOptions< TIdentifier, @@ -4750,9 +5232,25 @@ export type ChatAgentOptions< * `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning * `void` or nothing is the side-effect-only default. */ + /** + * Inbound `chat.event(...)` descriptors this agent handles. Listing one registers an agent-scoped + * webhook endpoint that routes verified deliveries to this agent's session; the delivery arrives at + * `onAction` as a `{ type, event, source, headers, deliveryId }` envelope whose `type` is the + * descriptor's `type`. The same descriptor listed on another agent gets its own endpoint. + */ + events?: TW; + + /** + * Inbound chat frontends (Slack, etc.) via `chat.channels.*`. Listing one registers an agent-scoped + * webhook endpoint that routes verified events to a durable per-key session and delivers them as + * turns: the connector's `inbound()` maps the raw event to the turn's message, `run()` fires as + * normal, and the reply streams back to the channel. Use the connector's `filter` to ignore events. + */ + channels?: TChannels; + onAction?: ( event: ActionEvent< - [TActionSchema] extends [TaskSchema] ? inferSchemaOut : unknown, + ChatActionType, inferSchemaOut, TUIMessage > @@ -5395,8 +5893,18 @@ function chatAgent< TUIMessage extends UIMessage = UIMessage, TActionSchema extends TaskSchema | undefined = undefined, TTools extends ToolSet = ToolSet, + const TW extends readonly AnyChatEvent[] = [], + const TChannels extends readonly AnyChannelConnector[] = [], >( - options: ChatAgentOptions + options: ChatAgentOptions< + TIdentifier, + TClientDataSchema, + TUIMessage, + TActionSchema, + TTools, + TW, + TChannels + > ): Task>, unknown> { const { run: userRun, @@ -5408,6 +5916,8 @@ function chatAgent< onValidateMessages, hydrateMessages, actionSchema, + events, + channels, onAction, onTurnStart, onBeforeTurnComplete, @@ -5781,6 +6291,10 @@ function chatAgent< // those route through the normal continuation-wait path. const hasRecoveredState = partialAssistant !== undefined; + if (couldHavePriorState && hasRecoveredState) { + locals.set(chatChannelRecoveryPendingKey, { value: true }); + } + let hookChain: TUIMessage[] | undefined; let hookRecoveredTurns: TUIMessage[] | undefined; let hookBeforeBoot: (() => Promise) | undefined; @@ -6408,6 +6922,9 @@ function chatAgent< let capturedPartialResponse: TUIMessage | undefined; let responseCommitted = false; const turnBufferedChunks: UIMessageChunk[] = []; + let channelConn: AnyChannelConnector | undefined; + let channelWorkingReaction: string | undefined; + let channelWireEvent: { event: unknown; deliveryId: string } | undefined; try { // Extract turn-level context before entering the span. Slim // wire: at most one delta message per record. `headStartMessages` @@ -6417,11 +6934,74 @@ function chatAgent< metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, + channelEvent: wireChannelEvent, ...restWire } = currentWirePayload; void _hsm; - const incomingMessages: TUIMessage[] = incomingMessage - ? [incomingMessage as TUIMessage] + channelWireEvent = wireChannelEvent; + let effectiveIncomingMessage = incomingMessage; + let channelAckRef: string | undefined; + if (wireChannelEvent) { + channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId); + if (channelConn) { + const interaction = channelConn.onInteraction?.(wireChannelEvent.event) ?? null; + const resolutionMessage = interaction + ? buildInteractionResolutionMessage( + interaction, + accumulatedUIMessages as UIMessage[] + ) + : undefined; + if (resolutionMessage) { + effectiveIncomingMessage = resolutionMessage as typeof incomingMessage; + if (interaction && channelConn.finalizeInteraction) { + try { + await channelConn.finalizeInteraction(wireChannelEvent.event, interaction); + } catch (finalizeError) { + logger.warn("chat.agent: channel finalizeInteraction failed; continuing", { + error: finalizeError, + }); + } + } + } else { + effectiveIncomingMessage = toUserUIMessage( + channelConn.inbound(wireChannelEvent.event), + currentWirePayload.messageId ?? wireChannelEvent.deliveryId + ) as typeof incomingMessage; + if (channelConn.send && channelConn.ack) { + const recoveryPending = locals.get(chatChannelRecoveryPendingKey); + const recovered = recoveryPending?.value === true; + if (recovered) recoveryPending!.value = false; + const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered }); + if (ackMessage) { + try { + const ackResult = await channelConn.send(ackMessage, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + mode: channelConn.delivery, + final: false, + }); + channelAckRef = ackResult?.ref; + } catch (ackError) { + logger.warn("chat.agent: channel ack post failed; continuing", { + error: ackError, + }); + } + } + } + channelWorkingReaction = await resolveReactionChoice( + channelConn.reactions?.working, + wireChannelEvent.event + ); + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: channelWorkingReaction, + }); + } + } + } + } + const incomingMessages: TUIMessage[] = effectiveIncomingMessage + ? [effectiveIncomingMessage as TUIMessage] : []; // Cleaning happens once here so `extractLastUserMessageText` and // every downstream consumer see the same message shape — and @@ -6580,9 +7160,11 @@ function chatAgent< let actionStreamResult: unknown = undefined; if (isAction) { // Parse and validate the action payload - const parsedAction = parseAction - ? await parseAction(currentWirePayload.action) - : currentWirePayload.action; + const isWebhookAction = currentWirePayload.actionSource === "webhook"; + const parsedAction = + parseAction && !isWebhookAction + ? await parseAction(currentWirePayload.action) + : currentWirePayload.action; // Hydrate messages from backend if configured if (hydrateMessages) { @@ -7157,6 +7739,7 @@ function chatAgent< signal: combinedSignal, cancelSignal, stopSignal, + channel: buildChannelRunSurface(channelConn, wireChannelEvent), } as any); } @@ -7199,7 +7782,32 @@ function chatAgent< resolveOnFinish!(); }, }); - await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), { + let streamForPipe: typeof uiStream = uiStream; + if ( + wireChannelEvent && + channelConn?.send && + channelConn.delivery === "stream" && + channelAckRef && + uiStream instanceof ReadableStream + ) { + const editor = makeChannelStreamEditor( + channelConn, + wireChannelEvent, + channelAckRef + ); + streamForPipe = uiStream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + editor.observe(chunk); + controller.enqueue(chunk); + }, + flush() { + editor.stop(); + }, + }) + ); + } + await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), { signal: combinedSignal, spanName: "stream response", }); @@ -7650,6 +8258,61 @@ function chatAgent< turnAccessToken ); + // Channel egress ("final"): map the turn's reply via outbound() and send it, editing + // the ack placeholder posted at turn start (previousRef). Best-effort: an egress failure + // is logged, not fatal. A manual-pipe turn has no responseMessage, so it opts out. + if (wireChannelEvent && channelConn?.send && turnCompleteEvent.responseMessage) { + const pendingToolCalls = pendingToolCallsInMessage( + turnCompleteEvent.responseMessage + ); + const channelMessage = + pendingToolCalls.length > 0 && channelConn.renderInteraction + ? channelConn.renderInteraction(pendingToolCalls, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + }) + : (channelConn.outbound ?? defaultChannelOutbound)({ + text: channelReplyText(turnCompleteEvent.responseMessage), + message: turnCompleteEvent.responseMessage, + final: true, + stopped: turnCompleteEvent.stopped, + }); + if (channelMessage) { + try { + await channelConn.send(channelMessage, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + previousRef: channelAckRef, + mode: channelConn.delivery, + final: true, + }); + } catch (egressError) { + logger.warn("chat.agent: channel egress send failed", { + error: egressError, + }); + } + } + } + + // Lifecycle reaction: turn done. Remove "working", mark "done". + if (wireChannelEvent && channelConn?.react) { + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: channelWorkingReaction, + remove: true, + }); + } + const doneReaction = await resolveReactionChoice( + channelConn.reactions?.done, + wireChannelEvent.event + ); + if (doneReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: doneReaction, + }); + } + } + // Fire onTurnComplete — stream is closed, use for persistence. if (onTurnComplete) { await tracer.startActiveSpan( @@ -7905,6 +8568,22 @@ function chatAgent< throw turnError; } + if (channelWireEvent && channelConn?.react) { + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, channelWireEvent, { + name: channelWorkingReaction, + remove: true, + }); + } + const errorReaction = await resolveReactionChoice( + channelConn.reactions?.error, + channelWireEvent.event + ); + if (errorReaction) { + await applyChannelReaction(channelConn, channelWireEvent, { name: errorReaction }); + } + } + let errorTurnCompleteResult: | Awaited> | undefined; @@ -8136,6 +8815,52 @@ function chatAgent< }); } + // Claim each listed chat.event descriptor: register an agent-scoped webhook endpoint that routes + // verified deliveries to THIS agent's session. The id is scoped by agent so the same descriptor on + // two agents yields two endpoints (no collision), each routing to its own agent. + if (events) { + for (const wh of events) { + resourceCatalog.markSessionWebhookClaimed(wh.id); + resourceCatalog.registerWebhookMetadata({ + id: `${options.id}:${wh.id}`, + source: wh.source, + verifierArtifact: wh.verifierArtifact, + secretProvisioning: wh.secretProvisioning, + filter: wh.filter, + routingTarget: { + type: "session", + taskIdentifier: options.id, + keyTemplate: wh.key, + actionType: wh.type, + deliverAs: "action", + }, + }); + } + } + + // Claim each listed channel connector: same agent-scoped endpoint, but deliverAs "message" so a + // verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId). + if (channels) { + for (const ch of channels) { + resourceCatalog.markSessionWebhookClaimed(ch.id); + resourceCatalog.registerWebhookMetadata({ + id: `${options.id}:${ch.id}`, + source: ch.source, + verifierArtifact: ch.verifierArtifact, + secretProvisioning: ch.secretProvisioning, + filter: ch.filter, + routingTarget: { + type: "session", + taskIdentifier: options.id, + keyTemplate: ch.key, + connectorId: ch.id, + deliverAs: "message", + startOn: ch.startOn, + }, + }); + } + } + return task; } @@ -10671,6 +11396,10 @@ async function mintPublicTokenWithOverride(args: { export const chat = { /** Create a chat agent. See {@link chatAgent}. */ agent: chatAgent, + /** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */ + event: chatEvent, + /** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */ + channels: chatChannels, /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */ customAgent: chatCustomAgent, /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */ @@ -10685,6 +11414,8 @@ export const chat = { local: chatLocal, /** Create a public access token for a chat task. See {@link createChatAccessToken}. */ createAccessToken: createChatAccessToken, + /** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */ + createWatchToken: createChatWatchToken, /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */ setTurnTimeout, /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */ diff --git a/packages/trigger-sdk/src/v3/channelReactions.test.ts b/packages/trigger-sdk/src/v3/channelReactions.test.ts new file mode 100644 index 00000000000..f9d806d0e05 --- /dev/null +++ b/packages/trigger-sdk/src/v3/channelReactions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveReactionChoice } from "./ai.js"; + +describe("resolveReactionChoice", () => { + it("returns a single string as-is", async () => { + expect(await resolveReactionChoice("eyes", {})).toBe("eyes"); + }); + + it("skips when absent or empty", async () => { + expect(await resolveReactionChoice(undefined, {})).toBeUndefined(); + expect(await resolveReactionChoice("", {})).toBeUndefined(); + expect(await resolveReactionChoice([], {})).toBeUndefined(); + expect(await resolveReactionChoice(() => undefined, {})).toBeUndefined(); + expect(await resolveReactionChoice(() => null, {})).toBeUndefined(); + }); + + it("picks a member of an array (randomly)", async () => { + const options = ["eyes", "hourglass", "thinking_face"]; + const seen = new Set(); + for (let i = 0; i < 60; i++) { + const picked = await resolveReactionChoice(options, {}); + expect(options).toContain(picked); + seen.add(picked!); + } + // Over 60 draws from 3 options, seeing only one is astronomically unlikely: proves it varies. + expect(seen.size).toBeGreaterThan(1); + }); + + it("resolves a function of the event, returning a string or an array", async () => { + const byKind = (e: unknown) => `emoji-${(e as { kind: string }).kind}`; + expect(await resolveReactionChoice(byKind, { kind: "bug" })).toBe("emoji-bug"); + const picked = await resolveReactionChoice(() => ["a", "b"], {}); + expect(["a", "b"]).toContain(picked); + }); + + it("awaits an async resolver", async () => { + expect(await resolveReactionChoice(async () => "shipit", {})).toBe("shipit"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 85f2ac75156..66c320b3e6c 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -97,6 +97,24 @@ export type ChatTaskWirePayload a chat frontend like Slack). Carries the raw + * verified provider event; the run resolves the connector by `connectorId` from `chat.agent({ channels })` + * and applies its `inbound()` mapper to produce the turn's message. Present instead of `message`. + */ + channelEvent?: { + connectorId: string; + event: unknown; + source: string; + headers: Record; + deliveryId: string; + }; /** Whether this run is continuing an existing chat whose previous run ended. */ continuation?: boolean; /** The run ID of the previous run (only set when `continuation` is true). */ diff --git a/packages/trigger-sdk/src/v3/webhooks.ts b/packages/trigger-sdk/src/v3/webhooks.ts index 040b7ee6638..132d9563141 100644 --- a/packages/trigger-sdk/src/v3/webhooks.ts +++ b/packages/trigger-sdk/src/v3/webhooks.ts @@ -1,5 +1,28 @@ -import { Webhook } from "@trigger.dev/core/v3"; +import { Webhook, resourceCatalog } from "@trigger.dev/core/v3"; +import type { + WebhookSource, + InferWebhookEvent, + AnyWebhookSource, + WebhookRunPayload, + ValidateWebhookFilter, + ChatEvent, + ValidatedWebhookKey, + WebhookVerifierConfig, + StripeWebhookEvent, + GitHubWebhookEvent, + TaskRunContext, +} from "@trigger.dev/core/v3"; import { subtle } from "../imports/uncrypto.js"; +import { createTask, type Task } from "./shared.js"; +import { + discordVerifierConfig, + githubVerifierConfig, + squareVerifierConfig, + stripeVerifierConfig, + svixVerifierConfig, + webhookProviderConfigs, + type WebhookProviderId, +} from "@trigger.dev/core/webhooks"; /** * The type of error thrown when a webhook fails to parse or verify @@ -24,6 +47,275 @@ type ConstructEventOptions = { header: string | Buffer | Array; }; +// ── Source producers (presets carry the event type) ── +export const webhookSources = { + custom(config: WebhookVerifierConfig): WebhookSource { + // Roll-your-own webhooks: you control both ends, so offer paste AND generate. + return { + provider: "custom", + verifier: { kind: "config", config }, + secretProvisioning: "either", + }; + }, + + // Stripe: `Stripe-Signature: t=…,v1=…` (comma-kv), signed `{t}.{body}`, hex. + // Defaults to a minimal event shape; pass the official type for full typing: stripe(). + stripe(opts?: { toleranceSeconds?: number }): WebhookSource { + return { + provider: "stripe", + verifier: { kind: "preset", preset: "stripe", config: stripeVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + // GitHub: `X-Hub-Signature-256: sha256=` (prefixed), signed raw body. + // Defaults to an open shape; pass your event type for full typing: github(). + github(): WebhookSource { + return { + provider: "github", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, + + // Svix family (Svix, Clerk, Resend): `svix-signature: v1, v1,` (space-list), + // signed `{id}.{timestamp}.{body}`, base64; the `whsec_` secret is base64-decoded. + svix(): WebhookSource { + return { + provider: "svix", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Square: bare base64 signature over `{notificationURL}{body}` (URL template var, no separator). + square(): WebhookSource { + return { + provider: "square", + verifier: { kind: "preset", preset: "square", config: squareVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Discord: asymmetric Ed25519 over `{timestamp}{body}`. The "secret" stored on the endpoint is + // the application PUBLIC KEY (hex by default). No shared secret. + discord(opts: { publicKeyEncoding?: "raw-hex" | "pem" } = {}): WebhookSource { + return { + provider: "discord", + verifier: { kind: "preset", preset: "discord", config: discordVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + /** + * Per-provider producers over shared presets. Each is a thin wrapper: same verifier config as the + * preset it references, differing only in `provider` (routing + picker identity) and who provisions + * the secret. Pass the provider's own published type for full typing, e.g. clerk(). + */ + clerk(): WebhookSource { + return { + provider: "clerk", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + resend(): WebhookSource { + return { + provider: "resend", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + openai(): WebhookSource { + return { + provider: "openai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + replicate(): WebhookSource { + return { + provider: "replicate", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + recallai(): WebhookSource { + return { + provider: "recall-ai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + brex(): WebhookSource { + return { + provider: "brex", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + gitlab(): WebhookSource { + return { + provider: "gitlab", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, + + whatsapp(): WebhookSource { + return { + provider: "whatsapp", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, +} as const; + +/** + * Per-provider producers generated from the core config table (kind "config"). Each carries the + * provider's own HMAC verifier config and stays in lockstep with the round-trip-tested configs. + */ +export type ProviderProducers = { + [K in WebhookProviderId]: () => WebhookSource; +}; + +export const providerProducers = Object.fromEntries( + Object.entries(webhookProviderConfigs).map(([provider, entry]) => [ + provider, + () => ({ + provider, + verifier: { kind: "config" as const, config: entry.config() }, + secretProvisioning: entry.secretProvisioning, + }), + ]) +) as ProviderProducers; + +// ── webhook() entry: single-callback IoC, infers event from source ── +export type WebhookOnEventParams = { + /** The verified event body, typed by the source (a preset type, or the `` you supply). */ + event: TEvent; + /** The inbound request headers (case-insensitive, Web `Headers`). e.g. headers.get("x-github-event"). */ + headers: Headers; + ctx: TaskRunContext; +}; + +export type WebhookOptions = { + id: TIdentifier; + source: TSource; + /** + * Optional server-side filter (a type-safe string DSL checked against the event shape). A delivery + * that doesn't match is received and recorded but not routed (no run). e.g. + * `"event.action == 'created' && event.repository.private == false"`. + */ + filter?: string; + onEvent: (params: WebhookOnEventParams>) => Promise | void; +}; + +export type WebhookHandle = Task; + +export function webhook< + TIdentifier extends string, + TSource extends AnyWebhookSource, + const TFilter extends string = string, +>( + options: WebhookOptions & { + filter?: TFilter & ValidateWebhookFilter, TFilter>; + } +): WebhookHandle> { + const { id, source, onEvent, filter } = options; + + // 1. The task half: webhook IS a first-class task kind (triggerSource "webhook"). + // The platform delivers a { event, headers } envelope; unwrap it for onEvent. The handle's + // payload type stays the event (webhook tasks are triggered by the ingress, not tasks.trigger). + const task = createTask, void>({ + id, + triggerSource: "webhook", + run: async (payload, runOptions) => { + const envelope = payload as unknown as WebhookRunPayload>; + await onEvent({ + event: envelope.event, + headers: new Headers(envelope.headers ?? {}), + ctx: runOptions.ctx, + }); + }, + }); + + // 2. The endpoint half: register the verifier + default routing target (this task) + filter. + resourceCatalog.registerWebhookMetadata({ + id, + source: source.provider, + verifierArtifact: source.verifier, + routingTarget: { type: "task", taskId: id }, + secretProvisioning: source.secretProvisioning, + filter, + }); + + return task; +} + +// ── chat.event(): declarative descriptor an agent claims via chat.agent({ events }). No handler. ── +// Carries the verifier (source), a validated string `key`, and a `type` discriminant. The `key` +// validates against the event/webhook/header namespaces and mirrors the stored {body.x} wire template. +export function chatEvent< + TSource extends AnyWebhookSource, + const TId extends string = string, + const TKey extends string = string, + const TType extends string = TId, + const TFilter extends string = string, +>(options: { + id: TId; + source: TSource; + key: ValidatedWebhookKey, TKey>; + /** The `action.type` the handler reads. Optional; defaults to `id`. */ + type?: TType; + /** Optional server-side filter (same type-safe DSL as `webhook()`); a non-match is recorded FILTERED and not routed. */ + filter?: TFilter & ValidateWebhookFilter, TFilter>; +}): ChatEvent> { + const { id, source, key, type, filter } = options; + const keyTemplate = normalizeKeyString(key as string); + + // Record the descriptor as declared so the indexer can flag it if no agent ever claims it. + resourceCatalog.registerDeclaredSessionWebhook(id); + + return { + id, + type: type ?? id, + key: keyTemplate, + source: source.provider, + verifierArtifact: source.verifier, + secretProvisioning: source.secretProvisioning, + filter, + } as ChatEvent>; +} + +// Public chat-event types (descriptor, the shared action union, and the key namespaces). +export type { + ChatEvent, + AnyChatEvent, + ChatEventAction, + ChatEventActions, + WebhookKeyMeta, +} from "@trigger.dev/core/v3"; + +// Brace placeholders without a recognized namespace default to the event body. webhook./header./body. +// pass through unchanged. +export function normalizeKeyString(key: string): string { + return key.replace(/\{([^}]+)\}/g, (_match, path: string) => + path.startsWith("webhook.") || path.startsWith("header.") || path.startsWith("body.") + ? `{${path}}` + : `{body.${path}}` + ); +} + +// P2 seam (TYPE only): +export type { CreateWebhookEndpointParams } from "@trigger.dev/core/v3"; + /** * Interface describing the webhook utilities */ @@ -50,14 +342,43 @@ interface Webhooks { /** Header name used for webhook signatures */ SIGNATURE_HEADER_NAME: string; + custom: typeof webhookSources.custom; + stripe: typeof webhookSources.stripe; + github: typeof webhookSources.github; + svix: typeof webhookSources.svix; + square: typeof webhookSources.square; + discord: typeof webhookSources.discord; + clerk: typeof webhookSources.clerk; + resend: typeof webhookSources.resend; + openai: typeof webhookSources.openai; + replicate: typeof webhookSources.replicate; + recallai: typeof webhookSources.recallai; + brex: typeof webhookSources.brex; + gitlab: typeof webhookSources.gitlab; + whatsapp: typeof webhookSources.whatsapp; } /** * Webhook utilities for handling incoming webhook requests */ -export const webhooks: Webhooks = { +export const webhooks: Webhooks & ProviderProducers = { + ...providerProducers, constructEvent, SIGNATURE_HEADER_NAME, + custom: webhookSources.custom, + stripe: webhookSources.stripe, + github: webhookSources.github, + svix: webhookSources.svix, + square: webhookSources.square, + discord: webhookSources.discord, + clerk: webhookSources.clerk, + resend: webhookSources.resend, + openai: webhookSources.openai, + replicate: webhookSources.replicate, + recallai: webhookSources.recallai, + brex: webhookSources.brex, + gitlab: webhookSources.gitlab, + whatsapp: webhookSources.whatsapp, }; async function constructEvent( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a59d9b72d6c..a3afde9f135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2229,6 +2229,28 @@ importers: specifier: ^1.7.0 version: 1.7.0 + packages/slack: + dependencies: + '@trigger.dev/core': + specifier: workspace:4.5.0-rc.7 + version: link:../core + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@trigger.dev/sdk': + specifier: workspace:4.5.0-rc.7 + version: link:../trigger-sdk + rimraf: + specifier: 6.0.1 + version: 6.0.1 + tshy: + specifier: ^3.0.2 + version: 3.3.2 + tsx: + specifier: 4.17.0 + version: 4.17.0 + packages/trigger-sdk: dependencies: '@ai-sdk/otel': @@ -8271,48 +8293,95 @@ packages: '@types/ws@8.5.4': resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-BvaaQAHWaHA0nl26DsWTsXsKkCHqUTm7f5FMuNDyCU83Hvo7zHx0vpSTrzL+1KEWeWLUVVGA7U224dk+3yIosQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-a2JJezvJAqWXgTAD1tKdWs7iA/4s3EakLcCYx4rg3ptEbBF6ADWv7A9ySHxT/+CQWYCD0DYIb9du1JUWL85fRw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-MQ0JcRucLdcR6MT14wlrGNz9HaxJrFF8Axmo0IN6e5gSou2UrKKUvvAH1i8zU5Gm7jl8CWCLzzX/qGCi1TsinA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-GLsTfJQiGfTN+r1ezlxMcTd5MNYRB/tADD6Y1j1jfLjZsFYSVkNfCnDQA/jwUG6GddBBF+0Um7tBUP16emej9w==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-u6gvCiVGSDagdR2+GI5VJLPxJbGevATJgjZ2QFLKBLWI3re7liTGlPAuaINNDq/r0m9rUPj2rEn0zwqtcLK0nQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-slxm6HBYs+jk0GpIBC2o03uY5qHgW7wIH+OTjK8JHbd2sUCgYV7P7bfZHUVZYi/cJZcVrnDW6MxXx734TuFn+w==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-G7SDZJn2Z9+c1qsAFzI/JL0OsRjJ18diQ39ycWKmJikilZZeL7iS7j933bemWY4ODaLTfxhMRKPMHf9292gpDA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] + '@typescript/native-preview@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-CO519Ccw5rji4JIG0DGVMR5owraCeQhm94jM53eRhMdlzz0nAJcAZ63Y6m1u3dUwqGssqlYxh4CcwTFPxTpMYw==} + engines: {node: '>=16.20.0'} + hasBin: true + '@typescript/native-preview@7.0.0-dev.20260707.2': resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} engines: {node: '>=16.20.0'} @@ -15255,6 +15324,11 @@ packages: unrun: optional: true + tshy@3.3.2: + resolution: {integrity: sha512-vOIXkqMtBWNjKUR/c99+6N50LhWdnKG1xE3+5wf8IPdzxx2lcIFPvbGgFdBBgoTMbdNb8mz06MUm7hY+TFnJcw==} + engines: {node: 20 || >=22} + hasBin: true + tshy@4.1.3: resolution: {integrity: sha512-uEaLO1lFhu5X58KZxS5gKCabh+xd72MfXDOHhAIvnoreBAP1F4HNpbK2m0DUmrrzpcgxvXbsdXn1A0OaQxFYqw==} engines: {node: 20 || >=22} @@ -19493,7 +19567,7 @@ snapshots: json-parse-even-better-errors: 3.0.0 normalize-package-data: 5.0.0 proc-log: 3.0.0 - semver: 7.8.1 + semver: 7.8.5 transitivePeerDependencies: - bluebird @@ -23350,27 +23424,58 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview@7.0.0-dev.20260603.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview@7.0.0-dev.20260707.2': optionalDependencies: '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 @@ -31458,6 +31563,22 @@ snapshots: - oxc-resolver - vue-tsc + tshy@3.3.2: + dependencies: + '@typescript/native-preview': 7.0.0-dev.20260603.1 + chalk: 5.6.2 + chokidar: 4.0.3 + foreground-child: 4.0.3 + jsonc-simple-parser: 3.0.0 + minimatch: 10.2.5 + mkdirp: 3.0.1 + polite-json: 5.0.0 + resolve-import: 2.4.0 + rimraf: 6.1.3 + sync-content: 2.0.4 + typescript: 5.9.3 + walk-up-path: 4.0.0 + tshy@4.1.3: dependencies: '@typescript/native-preview': 7.0.0-dev.20260707.2 From d1c027fcaf674c819c491c69f115fb51ed42895b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 13:05:07 +0100 Subject: [PATCH 02/13] fix(cli): send declared webhooks in the deploy worker metadata The deploy path now forwards declared webhooks to the server the same way dev does, so hosted webhook endpoints are created and stay active on deploy instead of only working under trigger dev. --- packages/cli-v3/src/entryPoints/managed-index-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index 248785782a1..aa7adb514c1 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -103,6 +103,7 @@ async function indexDeployment({ tasks: workerManifest.tasks, prompts: workerManifest.prompts, queues: workerManifest.queues, + webhooks: workerManifest.webhooks, sourceFiles, runtime: workerManifest.runtime, runtimeVersion: workerManifest.runtimeVersion, From a873cb46a3f99b1f97f5360b90781dc032765bf1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 14:38:33 +0100 Subject: [PATCH 03/13] fix(cli,core): fail indexing on duplicate webhook ids Two webhook() declarations sharing an id used to silently overwrite each other in the worker manifest. Indexing now fails with the colliding ids and their file paths, matching how duplicate task ids are already handled. --- .../src/entryPoints/dev-index-worker.ts | 6 ++++ .../src/entryPoints/managed-index-worker.ts | 6 ++++ .../src/indexing/indexWorkerManifest.ts | 8 +++++ .../src/indexing/reportWebhookIdCollisions.ts | 28 +++++++++++++++++ packages/core/src/v3/errors.ts | 31 +++++++++++++++++++ packages/core/src/v3/schemas/messages.ts | 4 +++ 6 files changed, 83 insertions(+) create mode 100644 packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index bf83ff8d720..e3f0984bf50 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -17,6 +17,7 @@ import { readFile } from "node:fs/promises"; import sourceMapSupport from "source-map-support"; import { registerResources } from "../indexing/registerResources.js"; import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js"; +import { reportWebhookIdCollisions } from "../indexing/reportWebhookIdCollisions.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; @@ -127,6 +128,11 @@ if (await reportTaskIdCollisions(safeSend)) { process.exit(0); } +if (await reportWebhookIdCollisions(safeSend)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + process.exit(0); +} + let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 8b3f2665518..54b0cc8a176 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -17,6 +17,7 @@ import { readFile } from "node:fs/promises"; import sourceMapSupport from "source-map-support"; import { registerResources } from "../indexing/registerResources.js"; import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js"; +import { reportWebhookIdCollisions } from "../indexing/reportWebhookIdCollisions.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; @@ -121,6 +122,11 @@ if (await reportTaskIdCollisions(safeSend)) { process.exit(0); } +if (await reportWebhookIdCollisions(safeSend)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + process.exit(0); +} + let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings diff --git a/packages/cli-v3/src/indexing/indexWorkerManifest.ts b/packages/cli-v3/src/indexing/indexWorkerManifest.ts index 7e8f7b1e002..33339fe9f17 100644 --- a/packages/cli-v3/src/indexing/indexWorkerManifest.ts +++ b/packages/cli-v3/src/indexing/indexWorkerManifest.ts @@ -1,6 +1,7 @@ import { execPathForRuntime } from "@trigger.dev/core/v3/build"; import { DuplicateTaskIdsError, + DuplicateWebhookIdsError, TaskIndexingImportError, TaskMetadataParseError, UncaughtExceptionError, @@ -94,6 +95,13 @@ export async function indexWorkerManifest({ child.kill("SIGKILL"); break; } + case "WEBHOOKS_FAILED_TO_INDEX": { + clearTimeout(timeout); + resolved = true; + reject(new DuplicateWebhookIdsError(message.payload.collisions)); + child.kill("SIGKILL"); + break; + } case "UNCAUGHT_EXCEPTION": { clearTimeout(timeout); resolved = true; diff --git a/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts b/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts new file mode 100644 index 00000000000..3a9c039ceda --- /dev/null +++ b/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts @@ -0,0 +1,28 @@ +import { indexerToWorkerMessages, resourceCatalog } from "@trigger.dev/core/v3"; +import { sendMessageInCatalog } from "@trigger.dev/core/v3/zodMessageHandler"; + +/** + * If the indexer registered any duplicate webhook ids (across files), report + * them to the parent via WEBHOOKS_FAILED_TO_INDEX and return true. Callers must + * stop indexing (skip INDEX_COMPLETE) when this returns true. + */ +export async function reportWebhookIdCollisions( + send: (message: unknown) => void +): Promise { + const collisions = resourceCatalog.listWebhookIdCollisions(); + + if (collisions.length === 0) { + return false; + } + + await sendMessageInCatalog( + indexerToWorkerMessages, + "WEBHOOKS_FAILED_TO_INDEX", + { collisions }, + async (msg) => { + send(msg); + } + ); + + return true; +} diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index 1fc7c5bce6c..ccf921a01d1 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -606,6 +606,37 @@ export class DuplicateTaskIdsError extends Error { } } +function formatDuplicateWebhookIds(collisions: TaskIdCollision[]): string { + const lines = collisions.map(({ id, filePaths }) => { + const distinct = Array.from(new Set(filePaths)); + + if (distinct.length === 1) { + return ` - "${id}" found more than once in ${distinct[0]}`; + } + + const last = distinct[distinct.length - 1]; + const head = distinct.slice(0, -1).join(", "); + + return ` - "${id}" found in ${head} and ${last}`; + }); + + return [ + "Duplicate webhook ids detected:", + "", + ...lines, + "", + "Webhook ids must be unique across your project. Please rename one of them.", + ].join("\n"); +} + +export class DuplicateWebhookIdsError extends Error { + constructor(public readonly collisions: TaskIdCollision[]) { + super(formatDuplicateWebhookIds(collisions)); + + this.name = "DuplicateWebhookIdsError"; + } +} + export class UnexpectedExitError extends Error { constructor( public code: number, diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index 1389fcddc40..65e495f3b4f 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -43,6 +43,10 @@ export const indexerToWorkerMessages = { version: z.literal("v1").default("v1"), collisions: z.array(z.object({ id: z.string(), filePaths: z.array(z.string()) })), }), + WEBHOOKS_FAILED_TO_INDEX: z.object({ + version: z.literal("v1").default("v1"), + collisions: z.array(z.object({ id: z.string(), filePaths: z.array(z.string()) })), + }), UNCAUGHT_EXCEPTION: UncaughtExceptionMessage, }; From 5d4c86f63bc39b366fb4fe9190a500375477b0d6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 19:43:26 +0100 Subject: [PATCH 04/13] fix(slack): truncate oversized tool input in approval blocks The HITL approval block serialized the tool input without a bound into a Slack section text field, which is capped near 3000 characters. A large input made chat.postMessage fail with invalid_blocks so the approve and deny controls never appeared. The serialized input is now capped to keep the block within the limit. --- packages/slack/src/index.test.ts | 19 +++++++++++++++++++ packages/slack/src/index.ts | 17 ++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/slack/src/index.test.ts b/packages/slack/src/index.test.ts index 2777eac61c9..08e2e0f47d7 100644 --- a/packages/slack/src/index.test.ts +++ b/packages/slack/src/index.test.ts @@ -50,6 +50,25 @@ describe("slack channel", () => { expect(values).toContain("call-1::deny"); }); + it("renderInteraction truncates an oversized tool input to stay under the Slack block limit", () => { + const c = slack({ id: "s-hitl-big", token: "t" }); + const msg = c.renderInteraction?.( + [ + { + toolCallId: "call-big", + toolName: "requestApproval", + input: { blob: "x".repeat(10_000) }, + }, + ], + { event: messageEvent(), deliveryId: "d1" } + ); + expect(msg).not.toBeNull(); + const section = (msg!.blocks as any[]).find((b) => b.type === "section"); + const text = section.text.text as string; + expect(text).toContain("... (truncated)"); + expect(text.length).toBeLessThan(3000); + }); + it("onInteraction resolves a block_actions click to a tool output; ignores messages", () => { const c = slack({ id: "s-hitl2", token: "t" }); const approve = c.onInteraction?.({ diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index a91c0de8e69..18eb3466c18 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -188,6 +188,13 @@ export function slack( }); } +/** + * Cap for the serialized tool input in an approval block. A Slack section `text` field accepts about + * 3000 characters; staying well under keeps a large input from failing chat.postMessage with + * `invalid_blocks` and dropping the approval controls. + */ +const MAX_INTERACTION_INPUT_CHARS = 2500; + /** * Default HITL controls: render each pending human-decision tool as a Block Kit approve/deny pair. The * button `value` carries `${toolCallId}::${decision}` so `onInteraction` can resolve the exact tool. @@ -198,7 +205,15 @@ function defaultSlackRenderInteraction( ): ChannelMessage | null { const call = pending[0]; if (!call) return null; - const detail = call.input !== undefined ? "\n```" + safeStringify(call.input) + "```" : ""; + let detail = ""; + if (call.input !== undefined) { + const serialized = safeStringify(call.input); + const shown = + serialized.length > MAX_INTERACTION_INPUT_CHARS + ? serialized.slice(0, MAX_INTERACTION_INPUT_CHARS) + "\n... (truncated)" + : serialized; + detail = "\n```" + shown + "```"; + } return { text: `Approval needed: ${call.toolName}`, blocks: [ From d915b8f57cf40bcb60e66b9c5af4afed65dde680 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 20:10:10 +0100 Subject: [PATCH 05/13] fix(sdk): drop stale channel interaction callbacks instead of starting a turn A channel interaction callback (for example a Slack button click) that resolves to a tool call with no matching pending tool part is a stale or duplicate callback. It was falling through to the inbound-message path, which acked, reacted, and ran a full agent turn. Such callbacks are now dropped: no turn runs and the run returns to its idle wait for the next message. --- packages/trigger-sdk/src/v3/ai.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5e85d38fb3b..eb4fb2dd569 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6925,6 +6925,7 @@ function chatAgent< let channelConn: AnyChannelConnector | undefined; let channelWorkingReaction: string | undefined; let channelWireEvent: { event: unknown; deliveryId: string } | undefined; + let droppedStaleInteraction = false; try { // Extract turn-level context before entering the span. Slim // wire: at most one delta message per record. `headStartMessages` @@ -6962,6 +6963,14 @@ function chatAgent< }); } } + } else if (interaction) { + droppedStaleInteraction = true; + logger.debug( + "chat.agent: dropping stale channel interaction that matched no pending tool call", + { + deliveryId: wireChannelEvent.deliveryId, + } + ); } else { effectiveIncomingMessage = toUserUIMessage( channelConn.inbound(wireChannelEvent.event), @@ -7244,7 +7253,7 @@ function chatAgent< // snapshot + `session.out` replay (or `hydrateMessages`, // which also fires per-turn below). Per-turn handling is // therefore a delta merge, not a full-history reset. - if (currentWirePayload.trigger !== "action") { + if (currentWirePayload.trigger !== "action" && !droppedStaleInteraction) { let cleanedUIMessages: TUIMessage[] = cleanedIncomingMessages; // Turn-0 head-start with hydrateMessages: the boot seeding from @@ -7543,7 +7552,7 @@ function chatAgent< turn--; } - if (!isAction) { + if (!isAction && !droppedStaleInteraction) { // Mint a scoped public access token once per turn, reused for // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk. const currentRunId = ctx.run.id; From ca72ead40b5f33aeabf4ab3b4720c06196875155 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 21:22:48 +0100 Subject: [PATCH 06/13] fix(slack): render approval controls for every pending tool call The HITL renderer only posted approve/deny buttons for the first pending tool call, so when a turn paused on multiple tool approvals the rest never got controls and the turn could not finish. It now renders a section plus an approve/deny pair for each pending call. --- packages/slack/src/index.test.ts | 16 ++++++++++++++ packages/slack/src/index.ts | 37 +++++++++++++++++++------------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/slack/src/index.test.ts b/packages/slack/src/index.test.ts index 08e2e0f47d7..d86fed8ef10 100644 --- a/packages/slack/src/index.test.ts +++ b/packages/slack/src/index.test.ts @@ -69,6 +69,22 @@ describe("slack channel", () => { expect(text.length).toBeLessThan(3000); }); + it("renderInteraction posts controls for every pending tool call, not just the first", () => { + const c = slack({ id: "s-hitl-multi", token: "t" }); + const msg = c.renderInteraction?.( + [ + { toolCallId: "call-a", toolName: "refund", input: { amount: 1 } }, + { toolCallId: "call-b", toolName: "sendEmail", input: { to: "x" } }, + ], + { event: messageEvent(), deliveryId: "d1" } + ); + expect(msg).not.toBeNull(); + const values = (msg!.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value); + expect(values).toEqual( + expect.arrayContaining(["call-a::approve", "call-a::deny", "call-b::approve", "call-b::deny"]) + ); + }); + it("onInteraction resolves a block_actions click to a tool output; ignores messages", () => { const c = slack({ id: "s-hitl2", token: "t" }); const approve = c.onInteraction?.({ diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 18eb3466c18..820d520a92c 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -203,20 +203,19 @@ function defaultSlackRenderInteraction( pending: ChannelPendingToolCall[], _ctx: ChannelInteractionCtx ): ChannelMessage | null { - const call = pending[0]; - if (!call) return null; - let detail = ""; - if (call.input !== undefined) { - const serialized = safeStringify(call.input); - const shown = - serialized.length > MAX_INTERACTION_INPUT_CHARS - ? serialized.slice(0, MAX_INTERACTION_INPUT_CHARS) + "\n... (truncated)" - : serialized; - detail = "\n```" + shown + "```"; - } - return { - text: `Approval needed: ${call.toolName}`, - blocks: [ + if (pending.length === 0) return null; + + const blocks = pending.flatMap((call) => { + let detail = ""; + if (call.input !== undefined) { + const serialized = safeStringify(call.input); + const shown = + serialized.length > MAX_INTERACTION_INPUT_CHARS + ? serialized.slice(0, MAX_INTERACTION_INPUT_CHARS) + "\n... (truncated)" + : serialized; + detail = "\n```" + shown + "```"; + } + return [ { type: "section", text: { type: "mrkdwn", text: `*Approval needed* for \`${call.toolName}\`${detail}` }, @@ -240,7 +239,15 @@ function defaultSlackRenderInteraction( }, ], }, - ], + ]; + }); + + return { + text: + pending.length === 1 + ? `Approval needed: ${pending[0]!.toolName}` + : `Approval needed: ${pending.length} tool calls`, + blocks, }; } From a259068c2191fa6d2fb048bb80d6d96359d9e81b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 21:31:04 +0100 Subject: [PATCH 07/13] chore(slack): declare @types/node explicitly instead of relying on hoisting The Slack package sets types: ["node"] in its tsconfig but did not declare @types/node, so type resolution depended on workspace hoisting. Declare it at the repo-pinned version, matching the other packages that opt into node types. vitest stays root-provided, consistent with every other package. --- packages/slack/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/slack/package.json b/packages/slack/package.json index fa8b483eaa5..62c6c9ec6eb 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@trigger.dev/sdk": "workspace:4.5.0-rc.7", + "@types/node": "^24.13.3", "rimraf": "6.0.1", "tshy": "^3.0.2", "tsx": "4.17.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3afde9f135..9bc6941b143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2241,6 +2241,9 @@ importers: '@trigger.dev/sdk': specifier: workspace:4.5.0-rc.7 version: link:../trigger-sdk + '@types/node': + specifier: 24.13.3 + version: 24.13.3 rimraf: specifier: 6.0.1 version: 6.0.1 From cc8f9d33bd571efed7a7c52df7031679886ad223 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 21:32:54 +0100 Subject: [PATCH 08/13] docs(webhooks): require channels:history for Slack message events The channels setup listed only chat:write, which posts replies but does not grant read access to message.channels events. Add channels:history to the app scopes and note that adding scopes after install requires a reinstall. --- docs/webhooks/channels.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/webhooks/channels.mdx b/docs/webhooks/channels.mdx index eb1d36ff499..ecfeefa925c 100644 --- a/docs/webhooks/channels.mdx +++ b/docs/webhooks/channels.mdx @@ -27,7 +27,7 @@ The `run()` loop is unchanged: the agent does not know or care that it is talkin - Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` bot scope and install it to your workspace to get a bot token (`xoxb-...`). + Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` and `channels:history` bot scopes (`chat:write` posts replies; `channels:history` receives the `message.channels` events), then install it to your workspace to get a bot token (`xoxb-...`). If you add scopes after installing, reinstall the app to apply them. Deploying registers a hosted [endpoint](/webhooks/connect) for the channel. Set its signing secret to your Slack app's **Signing Secret**, and pass the bot token as `token`. From 997aeb29fabf1e69092920d05efd2814d4dd4c92 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 21:41:49 +0100 Subject: [PATCH 09/13] fix(slack): bound the markdown link regex to avoid quadratic backtracking The link replacement in toSlackMrkdwn used unbounded character classes, which can backtrack quadratically on pathological input. Bound the link text and URL lengths and exclude newlines. Also tightened two test assertions that used unsafe optional chaining. --- packages/slack/src/index.test.ts | 5 +++-- packages/slack/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/slack/src/index.test.ts b/packages/slack/src/index.test.ts index d86fed8ef10..f080522c06f 100644 --- a/packages/slack/src/index.test.ts +++ b/packages/slack/src/index.test.ts @@ -45,7 +45,8 @@ describe("slack channel", () => { deliveryId: "d1", } ); - const values = (msg?.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value); + expect(msg).not.toBeNull(); + const values = (msg!.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value); expect(values).toContain("call-1::approve"); expect(values).toContain("call-1::deny"); }); @@ -129,7 +130,7 @@ describe("slack channel", () => { ); expect(calls[0]?.url).toBe("https://hooks.slack.test/r/1"); expect(calls[0]?.body.replace_original).toBe(true); - const types = (calls[0]?.body.blocks as Array<{ type: string }>).map((b) => b.type); + const types = (calls[0]!.body.blocks as Array<{ type: string }>).map((b) => b.type); expect(types).not.toContain("actions"); expect(JSON.stringify(calls[0]?.body.blocks)).toContain("Approved"); vi.unstubAllGlobals(); diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 820d520a92c..683a1f21a33 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -326,7 +326,7 @@ function defaultSlackInbound(event: SlackMessageEvent): string { */ export function toSlackMrkdwn(md: string): string { return md - .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>") + .replace(/\[([^\]\n]{1,300})\]\((https?:\/\/[^)\s]{1,2000})\)/g, "<$2|$1>") .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, "*$1*") .replace(/\*\*([^*\n]+)\*\*/g, "*$1*") .replace(/__([^_\n]+)__/g, "*$1*") From 73e592b5a7c6efbe70e2939ad35043d9099ad319 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 21:41:51 +0100 Subject: [PATCH 10/13] docs(webhooks): make example snippets self-contained with imports Several webhook doc code blocks used webhook, webhooks, streamText, anthropic, or chat without importing them, so a copied snippet would not type-check on its own. Add the imports to the standalone examples across sources, filters, channels, and human-in-the-loop. --- docs/webhooks/channels.mdx | 2 ++ docs/webhooks/filters.mdx | 2 ++ docs/webhooks/human-in-the-loop.mdx | 3 +++ docs/webhooks/sources.mdx | 6 ++++++ 4 files changed, 13 insertions(+) diff --git a/docs/webhooks/channels.mdx b/docs/webhooks/channels.mdx index ecfeefa925c..ce852420430 100644 --- a/docs/webhooks/channels.mdx +++ b/docs/webhooks/channels.mdx @@ -11,6 +11,8 @@ List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) ` ```ts import { chat } from "@trigger.dev/sdk/ai"; import { slack } from "@trigger.dev/slack"; +import { streamText } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; export const supportAgent = chat.agent({ id: "support-agent", diff --git a/docs/webhooks/filters.mdx b/docs/webhooks/filters.mdx index 4ab7f7f8483..1acce6ea85c 100644 --- a/docs/webhooks/filters.mdx +++ b/docs/webhooks/filters.mdx @@ -86,6 +86,8 @@ The type checker reads the filter as a token stream, so a couple of spots are st To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too): ```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + export const onGithub = webhook({ id: "github", source: webhooks.github(), diff --git a/docs/webhooks/human-in-the-loop.mdx b/docs/webhooks/human-in-the-loop.mdx index 1fc52cbce61..6c03be597c1 100644 --- a/docs/webhooks/human-in-the-loop.mdx +++ b/docs/webhooks/human-in-the-loop.mdx @@ -99,6 +99,9 @@ For [`chat.channels.custom`](/webhooks/channels), or to override the Slack defau Slack encodes the decision in each button's `value` as `${toolCallId}::approve|deny` so `onInteraction` can resolve the exact call, and `finalizeInteraction` posts to the interaction's `response_url` with `replace_original` to swap the buttons for the outcome. On a custom surface you choose the encoding and how you edit the controls away. ```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { webhooks } from "@trigger.dev/sdk"; + chat.channels.custom({ id: "my-surface", source: webhooks.custom({ /* verifier config */ }), diff --git a/docs/webhooks/sources.mdx b/docs/webhooks/sources.mdx index 7fa37067de1..b0221880fbd 100644 --- a/docs/webhooks/sources.mdx +++ b/docs/webhooks/sources.mdx @@ -27,6 +27,8 @@ export const stripeWebhook = webhook({ ``` ```ts GitHub +import { webhook, webhooks } from "@trigger.dev/sdk"; + export const githubWebhook = webhook({ id: "github", source: webhooks.github(), @@ -38,6 +40,8 @@ export const githubWebhook = webhook({ ``` ```ts Svix +import { webhook, webhooks } from "@trigger.dev/sdk"; + // Also covers Clerk, Resend, and other Svix-powered providers export const svixWebhook = webhook({ id: "svix", @@ -57,6 +61,8 @@ The available presets are `stripe()`, `github()`, `svix()`, `square()`, and `dis For a provider without a preset, `webhooks.custom()` describes the scheme as data. For example, an HMAC-SHA256 signature over the raw body, in a custom header: ```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + export const customWebhook = webhook({ id: "custom", source: webhooks.custom<{ id: string; message: string }>({ From 2b97fbbf22b60d37383cea97d7a72c6c363979f8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 22:52:53 +0100 Subject: [PATCH 11/13] test(sdk): channel connector support in the chat.agent test harness Add channel-event delivery to the mockChatAgent harness (sendChannelEvent) and a recordingChannelConnector helper to @trigger.dev/sdk/ai/test, so a chat.agent's channel round-trip (inbound mapping, ack placeholder, egress send, edit-in-place, and lifecycle reactions) can be driven and asserted entirely offline. --- packages/trigger-sdk/src/v3/test/index.ts | 11 ++ .../src/v3/test/mock-channel-connector.ts | 183 ++++++++++++++++++ .../src/v3/test/mock-chat-agent.ts | 74 +++++++ .../trigger-sdk/test/chatChannels.test.ts | 150 ++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 packages/trigger-sdk/src/v3/test/mock-channel-connector.ts create mode 100644 packages/trigger-sdk/test/chatChannels.test.ts diff --git a/packages/trigger-sdk/src/v3/test/index.ts b/packages/trigger-sdk/src/v3/test/index.ts index cdeded1a7a8..7f9b9e4a343 100644 --- a/packages/trigger-sdk/src/v3/test/index.ts +++ b/packages/trigger-sdk/src/v3/test/index.ts @@ -8,11 +8,22 @@ import "./setup-catalog.js"; export { mockChatAgent, + DEFAULT_TEST_CONNECTOR_ID, type MockChatAgentOptions, type MockChatAgentHarness, type MockChatAgentTurn, } from "./mock-chat-agent.js"; +export { + recordingChannelConnector, + type RecordingChannelConnector, + type RecordingChannelConnectorOptions, + type RecordedSend, + type RecordedReaction, + type RecordedFinalize, + type TestChannelEvent, +} from "./mock-channel-connector.js"; + // Re-export the lower-level task context harness so consumers can build // their own test helpers without adding a separate `@trigger.dev/core` // dependency to their reference projects. diff --git a/packages/trigger-sdk/src/v3/test/mock-channel-connector.ts b/packages/trigger-sdk/src/v3/test/mock-channel-connector.ts new file mode 100644 index 00000000000..7f48568e459 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/mock-channel-connector.ts @@ -0,0 +1,183 @@ +import { chat } from "../ai.js"; +import type { + ChannelAckCtx, + ChannelConnector, + ChannelInteractionCtx, + ChannelInteractionResolution, + ChannelMessage, + ChannelMessageInput, + ChannelPendingToolCall, + ChannelReaction, + ChannelReactions, + ChannelSendCtx, +} from "../ai.js"; +import { webhooks } from "../webhooks.js"; +import { DEFAULT_TEST_CONNECTOR_ID } from "./mock-chat-agent.js"; + +/** The default event shape {@link recordingChannelConnector} maps when no `inbound` is given. */ +export type TestChannelEvent = { text: string; threadId?: string }; + +/** A single `send()` call captured by {@link recordingChannelConnector}. */ +export type RecordedSend = { + /** The channel message the connector was asked to post (or edit into place). */ + message: ChannelMessage; + /** The egress context: `final`, `mode`, `previousRef`, the raw `event`, `deliveryId`. */ + ctx: ChannelSendCtx; + /** The ref this send resolved to (echoes `previousRef` on an edit, else a fresh id). */ + ref: string; +}; + +/** A reaction applied to the triggering message, captured by {@link recordingChannelConnector}. */ +export type RecordedReaction = { reaction: ChannelReaction; event: TEvent }; + +/** A HITL `finalizeInteraction()` call, captured by {@link recordingChannelConnector}. */ +export type RecordedFinalize = { + event: TEvent; + resolution: ChannelInteractionResolution; +}; + +/** + * A real {@link ChannelConnector} whose egress hooks record what they were + * asked to do instead of touching a network, so a test can assert the channel + * round-trip a `chat.agent` turn produced. Built through the real + * `chat.channels.custom` factory, so it is a genuinely-shaped connector; only + * `send` / `ack` / `react` / `finalizeInteraction` are swapped for recorders. + */ +export type RecordingChannelConnector = ChannelConnector & { + /** Every `send()` call in order: the ack, any stream edits, and the final reply. */ + readonly sent: ReadonlyArray>; + /** Turn-start placeholder posts ("final" delivery): `final: false`, no `previousRef`. */ + readonly acks: ReadonlyArray>; + /** Stream-mode intermediate edits: `mode: "stream"`, `final: false`, with `previousRef`. */ + readonly edits: ReadonlyArray>; + /** Every reaction applied to the triggering message (lifecycle + `run().channel`). */ + readonly reactionsApplied: ReadonlyArray>; + /** Every HITL `finalizeInteraction()` call. */ + readonly finalized: ReadonlyArray>; + /** The ref of the most recent `send()`, i.e. the current edit target. */ + readonly lastRef: string | undefined; + /** Text of the final reply (`final: true`), or `undefined` if none posted yet. */ + finalText(): string | undefined; +}; + +/** Options for {@link recordingChannelConnector}. */ +export type RecordingChannelConnectorOptions = { + /** Connector id. Defaults to {@link DEFAULT_TEST_CONNECTOR_ID} so it lines up with `sendChannelEvent`. */ + id?: string; + /** `"final"` (default: ack then edit-to-answer) or `"stream"` (debounced live edits). */ + delivery?: "final" | "stream"; + /** Session key template. Unused in-run (server-side routing only); defaults to `"{body.threadId}"`. */ + key?: string; + /** Map the raw event to the turn's message. Defaults to reading `event.text`. */ + inbound?: (event: TEvent) => ChannelMessageInput; + /** + * Placeholder posted at turn start ("final" delivery). Defaults to `{ text: "..." }`. + * Pass `null` (or a function returning `null`) to post no ack, so the final reply + * arrives as a fresh message instead of an edit. + */ + ack?: ChannelMessage | null | ((event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null); + /** HITL: map a verified callback event to a tool resolution (null => treat as a normal message). */ + onInteraction?: (event: TEvent) => ChannelInteractionResolution | null; + /** HITL: map the pending tool call(s) to the controls posted in the thread. */ + renderInteraction?: ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx + ) => ChannelMessage | null; + /** Lifecycle reaction choices (working/done/error). Requires nothing extra; `react` is always recorded. */ + reactions?: ChannelReactions; +}; + +/** + * Create a {@link RecordingChannelConnector} for driving a `chat.agent` + * channel turn offline. Pair it with `mockChatAgent(...).sendChannelEvent(...)`: + * list the connector on the agent's `channels`, deliver an event, then assert + * against `connector.sent` / `.acks` / `.edits` / `.finalText()`. + * + * @example + * ```ts + * const channel = recordingChannelConnector(); + * const agent = chat.agent({ id: "support", channels: [channel], run: ... }); + * const harness = mockChatAgent(agent); + * await harness.sendChannelEvent({ event: { text: "hi", threadId: "t1" } }); + * expect(channel.finalText()).toBe("hello"); + * ``` + */ +export function recordingChannelConnector( + options: RecordingChannelConnectorOptions = {} +): RecordingChannelConnector { + const sent: RecordedSend[] = []; + const reactionsApplied: RecordedReaction[] = []; + const finalized: RecordedFinalize[] = []; + let refCounter = 0; + let lastRef: string | undefined; + + const inbound = options.inbound ?? ((event: TEvent) => (event as { text?: string })?.text ?? ""); + + const ackFn: (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null = + options.ack === undefined + ? () => ({ text: "..." }) + : typeof options.ack === "function" + ? (options.ack as (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null) + : () => options.ack as ChannelMessage | null; + + const send = async (message: ChannelMessage, ctx: ChannelSendCtx) => { + const ref = ctx.previousRef ?? `ref_${++refCounter}`; + sent.push({ message, ctx, ref }); + lastRef = ref; + return { ref }; + }; + + const react = async (reaction: ChannelReaction, ctx: { event: TEvent }) => { + reactionsApplied.push({ reaction, event: ctx.event }); + }; + + const finalizeInteraction = async (event: TEvent, resolution: ChannelInteractionResolution) => { + finalized.push({ event, resolution }); + }; + + const connector = chat.channels.custom({ + id: options.id ?? DEFAULT_TEST_CONNECTOR_ID, + source: webhooks.custom({ + scheme: "shared-secret", + placement: "header", + fieldName: "x-test-signature", + }), + key: (options.key ?? "{body.threadId}") as never, + inbound: inbound as (event: TEvent) => ChannelMessageInput, + ack: ackFn as never, + send: send as never, + react: react as never, + finalizeInteraction: finalizeInteraction as never, + ...(options.onInteraction ? { onInteraction: options.onInteraction as never } : {}), + ...(options.renderInteraction ? { renderInteraction: options.renderInteraction as never } : {}), + ...(options.reactions ? { reactions: options.reactions as never } : {}), + delivery: options.delivery ?? "final", + }) as ChannelConnector; + + Object.defineProperties(connector, { + sent: { get: () => sent, enumerable: true }, + reactionsApplied: { get: () => reactionsApplied, enumerable: true }, + finalized: { get: () => finalized, enumerable: true }, + lastRef: { get: () => lastRef, enumerable: true }, + acks: { + get: () => sent.filter((s) => s.ctx.final === false && s.ctx.previousRef === undefined), + enumerable: true, + }, + edits: { + get: () => + sent.filter( + (s) => s.ctx.mode === "stream" && s.ctx.final === false && s.ctx.previousRef !== undefined + ), + enumerable: true, + }, + }); + + (connector as RecordingChannelConnector).finalText = () => { + for (let i = sent.length - 1; i >= 0; i--) { + if (sent[i]!.ctx.final) return sent[i]!.message.text; + } + return undefined; + }; + + return connector as RecordingChannelConnector; +} diff --git a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts index a4cbe7b33a0..f58ad5ed56e 100644 --- a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts +++ b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts @@ -36,6 +36,19 @@ type ChatWirePayload = { messageId?: string; metadata?: unknown; action?: unknown; + /** + * A channel-delivered turn (Slack or any `chat.channels.*` connector). Carries + * the raw verified provider event; the run resolves the connector by + * `connectorId` from `chat.agent({ channels })` and applies its `inbound()` + * mapper to produce the turn's message. Present instead of `message`. + */ + channelEvent?: { + connectorId: string; + event: unknown; + source: string; + headers: Record; + deliveryId: string; + }; continuation?: boolean; previousRunId?: string; idleTimeoutInSeconds?: number; @@ -187,6 +200,25 @@ export type MockChatAgentHarness = { /** Send a custom action and wait for the next turn-complete. */ sendAction(action: unknown): Promise; + /** + * Deliver a verified channel event (Slack or any `chat.channels.*` + * connector) and wait for the next turn-complete. Mirrors what the hosted + * webhook ingress appends to `session.in`: a `submit-message` wire payload + * carrying `channelEvent` instead of `message`. The run resolves the + * connector by `connectorId`, maps the event with its `inbound()`, runs the + * turn, and posts the reply back through the connector's `send()`. + * + * `connectorId` defaults to the `id` of the connector on the agent when the + * agent lists exactly one; pass it explicitly for multi-connector agents. + */ + sendChannelEvent(args: { + event: unknown; + connectorId?: string; + source?: string; + headers?: Record; + deliveryId?: string; + }): Promise; + /** Fire a stop signal. Does not wait for the turn — the task keeps running. */ sendStop(message?: string): Promise; @@ -278,6 +310,28 @@ export type MockChatAgentHarness = { readonly allRawChunks: unknown[]; }; +/** + * Default `connectorId` used by {@link MockChatAgentHarness.sendChannelEvent} + * when the caller omits it. Matches the default `id` of + * {@link recordingChannelConnector}, so a single-connector agent needs no + * explicit id on either side. + */ +export const DEFAULT_TEST_CONNECTOR_ID = "test-channel"; + +/** + * Wait for a channel turn's post-completion egress to land. The run loop + * writes the `trigger:turn-complete` chunk (which unblocks the harness's + * turn-complete latch) BEFORE it awaits the connector's final `send()` and + * its done/error reactions. Draining a handful of macrotasks lets those + * awaited-but-immediate calls settle so `sendChannelEvent` callers can assert + * against the connector's recorded sends/reactions deterministically. + */ +async function settlePostTurnChannelEgress(): Promise { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + const CONTROL_CHUNK_TYPES = new Set(["trigger:turn-complete", "trigger:upgrade-required"]); function isControlChunk(chunk: unknown): boolean { @@ -366,6 +420,8 @@ export function mockChatAgent( let closeSessionInput: ((sessionId: string) => void) | undefined; let runSignal!: AbortController; + let channelDeliveryCounter = 0; + // A latch that resolves every time `trigger:turn-complete` appears on the chat stream. // We use a shared pending promise and replace it after each completion. let turnCompleteResolvers: Array<() => void> = []; @@ -617,6 +673,24 @@ export function mockChatAgent( }); }, + async sendChannelEvent(args) { + const deliveryId = args.deliveryId ?? `dlv_${++channelDeliveryCounter}`; + const turn = await sendPayloadAndWait({ + chatId, + trigger: "submit-message", + channelEvent: { + connectorId: args.connectorId ?? DEFAULT_TEST_CONNECTOR_ID, + event: args.event, + source: args.source ?? "custom", + headers: args.headers ?? {}, + deliveryId, + }, + metadata: clientData, + }); + await settlePostTurnChannelEgress(); + return turn; + }, + async sendStop(message) { await harnessReady; await sendSessionInput(sessionId, { kind: "stop", message }); diff --git a/packages/trigger-sdk/test/chatChannels.test.ts b/packages/trigger-sdk/test/chatChannels.test.ts new file mode 100644 index 00000000000..96310c8e227 --- /dev/null +++ b/packages/trigger-sdk/test/chatChannels.test.ts @@ -0,0 +1,150 @@ +import { mockChatAgent, recordingChannelConnector } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +function textStream(text: string) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ]; + return simulateReadableStream({ chunks }); +} + +describe("chat.agent channels", () => { + it("delivers a channel event as a turn and posts the reply back through the connector", async () => { + const seenText: string[] = []; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hello world") }), + }); + + const channel = recordingChannelConnector(); + + const agent = chat.agent({ + id: "chatChannels.final-roundtrip", + channels: [channel], + run: async ({ messages, signal }) => { + const last = messages[messages.length - 1]; + const content = last?.content; + seenText.push( + typeof content === "string" + ? content + : (content ?? []) + .map((p) => (typeof p === "object" && p.type === "text" ? p.text : "")) + .join("") + ); + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "chan-1" }); + try { + await harness.sendChannelEvent({ event: { text: "hi there", threadId: "chan-1" } }); + + expect(seenText).toEqual(["hi there"]); + + expect(channel.acks).toHaveLength(1); + expect(channel.acks[0]!.message.text).toBe("..."); + expect(channel.finalText()).toBe("hello world"); + + const finalSend = channel.sent[channel.sent.length - 1]!; + expect(finalSend.ctx.final).toBe(true); + expect(finalSend.ctx.previousRef).toBe(channel.acks[0]!.ref); + } finally { + await harness.close(); + } + }); + + it("posts only the final answer when ack is null", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("done") }), + }); + + const channel = recordingChannelConnector({ ack: null }); + + const agent = chat.agent({ + id: "chatChannels.no-ack", + channels: [channel], + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "chan-2" }); + try { + await harness.sendChannelEvent({ event: { text: "yo", threadId: "chan-2" } }); + + expect(channel.acks).toHaveLength(0); + expect(channel.sent).toHaveLength(1); + expect(channel.finalText()).toBe("done"); + } finally { + await harness.close(); + } + }); + + it("records lifecycle reactions around a channel turn", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + const channel = recordingChannelConnector({ + reactions: { working: "eyes", done: "white_check_mark" }, + }); + + const agent = chat.agent({ + id: "chatChannels.reactions", + channels: [channel], + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "chan-3" }); + try { + await harness.sendChannelEvent({ event: { text: "go", threadId: "chan-3" } }); + + const names = channel.reactionsApplied.map((r) => ({ + name: r.reaction.name, + remove: r.reaction.remove ?? false, + })); + expect(names).toEqual([ + { name: "eyes", remove: false }, + { name: "eyes", remove: true }, + { name: "white_check_mark", remove: false }, + ]); + } finally { + await harness.close(); + } + }); + + it("still posts an authoritative final reply in stream delivery", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("streamed answer") }), + }); + + const channel = recordingChannelConnector({ delivery: "stream" }); + + const agent = chat.agent({ + id: "chatChannels.stream-final", + channels: [channel], + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "chan-4" }); + try { + await harness.sendChannelEvent({ event: { text: "stream please", threadId: "chan-4" } }); + + expect(channel.finalText()).toBe("streamed answer"); + } finally { + await harness.close(); + } + }); +}); From ccf96a863c5f74321e6f762d0fb43f66b28fc6cb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 23:07:08 +0100 Subject: [PATCH 12/13] fix(sdk): channel stream editor tail, abort cleanup, and error egress Three fixes to chat.agent channel egress: The debounced stream editor now re-arms after an edit it skipped because a previous edit was still in flight, so text buffered during that window still reaches the channel instead of stalling until the next delta. The stream editor is stopped when the reply stream is aborted or cancelled, not just on normal completion, so a late timer can no longer edit the channel message after the turn has ended. A turn that throws now edits the placeholder to show the error, so a channel user sees the failure instead of a message stuck on the loading placeholder. --- packages/trigger-sdk/src/v3/ai.ts | 98 ++++++++++--- .../trigger-sdk/test/chatChannels.test.ts | 131 +++++++++++++++++- 2 files changed, 211 insertions(+), 18 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index eb4fb2dd569..ac4ad3f12cc 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -5057,13 +5057,23 @@ function makeChannelStreamEditor( ) { const outbound = connector.outbound ?? defaultChannelOutbound; let latest = ""; + let lastSent = ""; let timer: ReturnType | undefined; let inFlight = false; let stopped = false; + const arm = () => { + if (stopped || timer) return; + timer = setTimeout(() => { + timer = undefined; + void edit(); + }, CHANNEL_STREAM_EDIT_INTERVAL_MS); + }; + const edit = async () => { if (stopped || inFlight) return; const text = latest; + if (text === lastSent) return; const message = outbound({ text, message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage, @@ -5080,10 +5090,12 @@ function makeChannelStreamEditor( mode: "stream", final: false, }); + lastSent = text; } catch (error) { logger.warn("chat.agent: channel stream edit failed", { error }); } finally { inFlight = false; + if (!stopped && latest !== lastSent) arm(); } }; @@ -5093,11 +5105,7 @@ function makeChannelStreamEditor( const c = chunk as { type?: string; delta?: unknown }; if (c?.type === "text-delta" && typeof c.delta === "string") { latest += c.delta; - if (!timer) - timer = setTimeout(() => { - timer = undefined; - void edit(); - }, CHANNEL_STREAM_EDIT_INTERVAL_MS); + arm(); } }, stop() { @@ -5110,6 +5118,39 @@ function makeChannelStreamEditor( }; } +type ChannelStreamEditor = { observe(chunk: unknown): void; stop(): void }; + +/** + * Wrap the reply stream so it debounce-edits the channel message as it streams. + * Both `flush` (the stream completed) and `cancel` (the stream was aborted or + * cancelled downstream) stop the editor, so a pending debounce timer can never + * fire an edit after the turn ended, onto a message the next turn already owns. + */ +function makeChannelStreamTap(editor: ChannelStreamEditor): TransformStream { + const transformer: { + transform(chunk: unknown, controller: TransformStreamDefaultController): void; + flush(): void; + cancel?: (reason?: unknown) => void; + } = { + transform(chunk, controller) { + editor.observe(chunk); + controller.enqueue(chunk); + }, + flush() { + editor.stop(); + }, + cancel() { + editor.stop(); + }, + }; + return new TransformStream(transformer); +} + +export { + makeChannelStreamEditor as __makeChannelStreamEditorForTests, + makeChannelStreamTap as __makeChannelStreamTapForTests, +}; + // The `action` type onAction receives: the actionSchema output (when set) unioned with the action // envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses // cleanly when no events are listed; `unknown` is kept only when neither source is present. @@ -6925,6 +6966,8 @@ function chatAgent< let channelConn: AnyChannelConnector | undefined; let channelWorkingReaction: string | undefined; let channelWireEvent: { event: unknown; deliveryId: string } | undefined; + let channelAckRef: string | undefined; + let channelStreamEditor: ChannelStreamEditor | undefined; let droppedStaleInteraction = false; try { // Extract turn-level context before entering the span. Slim @@ -6941,7 +6984,6 @@ function chatAgent< void _hsm; channelWireEvent = wireChannelEvent; let effectiveIncomingMessage = incomingMessage; - let channelAckRef: string | undefined; if (wireChannelEvent) { channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId); if (channelConn) { @@ -7799,21 +7841,13 @@ function chatAgent< channelAckRef && uiStream instanceof ReadableStream ) { - const editor = makeChannelStreamEditor( + channelStreamEditor = makeChannelStreamEditor( channelConn, wireChannelEvent, channelAckRef ); streamForPipe = uiStream.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - editor.observe(chunk); - controller.enqueue(chunk); - }, - flush() { - editor.stop(); - }, - }) + makeChannelStreamTap(channelStreamEditor) ); } await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), { @@ -7833,6 +7867,7 @@ function chatAgent< } } finally { msgSub.off(); + channelStreamEditor?.stop(); } // Wait for onFinish to fire — on abort this may resolve slightly @@ -8593,6 +8628,37 @@ function chatAgent< } } + if (channelWireEvent && channelConn?.send && channelAckRef) { + const channelErrorText = + turnError instanceof Error ? turnError.message : "An unexpected error occurred"; + const errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({ + text: channelErrorText, + message: { + id: channelAckRef, + role: "assistant", + parts: [{ type: "text", text: channelErrorText }], + } as UIMessage, + final: true, + stopped: false, + error: turnError, + }); + if (errorMessage) { + try { + await channelConn.send(errorMessage, { + event: channelWireEvent.event, + deliveryId: channelWireEvent.deliveryId, + previousRef: channelAckRef, + mode: channelConn.delivery, + final: true, + }); + } catch (egressError) { + logger.warn("chat.agent: channel error egress send failed", { + error: egressError, + }); + } + } + } + let errorTurnCompleteResult: | Awaited> | undefined; diff --git a/packages/trigger-sdk/test/chatChannels.test.ts b/packages/trigger-sdk/test/chatChannels.test.ts index 96310c8e227..8b41206d98e 100644 --- a/packages/trigger-sdk/test/chatChannels.test.ts +++ b/packages/trigger-sdk/test/chatChannels.test.ts @@ -1,7 +1,11 @@ import { mockChatAgent, recordingChannelConnector } from "../src/v3/test/index.js"; -import { describe, expect, it } from "vitest"; -import { chat } from "../src/v3/ai.js"; +import { describe, expect, it, vi } from "vitest"; +import { + chat, + __makeChannelStreamEditorForTests, + __makeChannelStreamTapForTests, +} from "../src/v3/ai.js"; import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; @@ -147,4 +151,127 @@ describe("chat.agent channels", () => { await harness.close(); } }); + + it("edits the placeholder to the error when a channel turn throws", async () => { + const channel = recordingChannelConnector(); + + const agent = chat.agent({ + id: "chatChannels.error-egress", + channels: [channel], + run: async () => { + throw new Error("turn exploded"); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "chan-5" }); + try { + await harness.sendChannelEvent({ event: { text: "cause an error", threadId: "chan-5" } }); + + expect(channel.acks).toHaveLength(1); + const finalSend = channel.sent[channel.sent.length - 1]!; + expect(finalSend.ctx.final).toBe(true); + expect(finalSend.ctx.previousRef).toBe(channel.acks[0]!.ref); + expect(finalSend.message.text).toBe("turn exploded"); + } finally { + await harness.close(); + } + }); +}); + +describe("makeChannelStreamEditor", () => { + function streamConnector(send: (message: { text: string }) => Promise<{ ref?: string }>) { + return { delivery: "stream" as const, send } as never; + } + + it("re-arms the debounce timer so text buffered during an in-flight edit still lands", async () => { + vi.useFakeTimers(); + try { + const sends: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let call = 0; + const editor = __makeChannelStreamEditorForTests( + streamConnector(async (message) => { + sends.push(message.text); + call += 1; + if (call === 1) await firstGate; + return { ref: "r1" }; + }), + { event: {}, deliveryId: "d1" }, + "r1" + ); + + editor.observe({ type: "text-delta", delta: "a" }); + await vi.advanceTimersByTimeAsync(1000); + expect(sends).toEqual(["a"]); + + editor.observe({ type: "text-delta", delta: "b" }); + await vi.advanceTimersByTimeAsync(1000); + expect(sends).toEqual(["a"]); + + releaseFirst(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + expect(sends).toEqual(["a", "ab"]); + } finally { + vi.useRealTimers(); + } + }); + + it("does not fire a pending edit after stop()", async () => { + vi.useFakeTimers(); + try { + const sends: string[] = []; + const editor = __makeChannelStreamEditorForTests( + streamConnector(async (message) => { + sends.push(message.text); + return { ref: "r1" }; + }), + { event: {}, deliveryId: "d1" }, + "r1" + ); + + editor.observe({ type: "text-delta", delta: "a" }); + editor.stop(); + await vi.advanceTimersByTimeAsync(1000); + expect(sends).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("makeChannelStreamTap", () => { + it("stops the editor when the stream completes (flush)", async () => { + let stops = 0; + const tap = __makeChannelStreamTapForTests({ observe: () => {}, stop: () => (stops += 1) }); + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: "x" }); + controller.close(); + }, + }); + const reader = source.pipeThrough(tap).getReader(); + let done = false; + while (!done) { + done = (await reader.read()).done; + } + expect(stops).toBeGreaterThan(0); + }); + + it("stops the editor when the stream is cancelled mid-flight (abort)", async () => { + let stops = 0; + const tap = __makeChannelStreamTapForTests({ observe: () => {}, stop: () => (stops += 1) }); + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-delta", delta: "x" }); + }, + }); + const reader = source.pipeThrough(tap).getReader(); + await reader.read(); + await reader.cancel("aborted"); + expect(stops).toBeGreaterThan(0); + }); }); From 4b55ab5565a82625256fd7a7b9112a9a07bba03c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 23:16:59 +0100 Subject: [PATCH 13/13] test(sdk): channel interaction coverage in the chat.agent test harness Add a fire-and-forget deliverChannelEvent to the harness and loop-level tests for the channel interaction paths: a resolved interaction callback resumes the pending tool and finalizes the controls, and a stale callback that matches no pending tool is dropped without running a turn or posting anything. --- .../src/v3/test/mock-chat-agent.ts | 35 ++++++ .../trigger-sdk/test/chatChannels.test.ts | 104 +++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts index f58ad5ed56e..feff2582267 100644 --- a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts +++ b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts @@ -219,6 +219,20 @@ export type MockChatAgentHarness = { deliveryId?: string; }): Promise; + /** + * Deliver a channel event without waiting for a turn. Mirrors an event that + * the run may not turn into a turn: a filtered or deduped delivery, or a + * stale interaction callback that is dropped (matches no pending tool call). + * Resolves once the run has had a chance to process and possibly drop it. + */ + deliverChannelEvent(args: { + event: unknown; + connectorId?: string; + source?: string; + headers?: Record; + deliveryId?: string; + }): Promise; + /** Fire a stop signal. Does not wait for the turn — the task keeps running. */ sendStop(message?: string): Promise; @@ -691,6 +705,27 @@ export function mockChatAgent( return turn; }, + async deliverChannelEvent(args) { + await harnessReady; + const deliveryId = args.deliveryId ?? `dlv_${++channelDeliveryCounter}`; + await sendSessionInput(sessionId, { + kind: "message", + payload: { + chatId, + trigger: "submit-message", + channelEvent: { + connectorId: args.connectorId ?? DEFAULT_TEST_CONNECTOR_ID, + event: args.event, + source: args.source ?? "custom", + headers: args.headers ?? {}, + deliveryId, + }, + metadata: clientData, + }, + }); + await settlePostTurnChannelEgress(); + }, + async sendStop(message) { await harnessReady; await sendSessionInput(sessionId, { kind: "stop", message }); diff --git a/packages/trigger-sdk/test/chatChannels.test.ts b/packages/trigger-sdk/test/chatChannels.test.ts index 8b41206d98e..55f441fd4e4 100644 --- a/packages/trigger-sdk/test/chatChannels.test.ts +++ b/packages/trigger-sdk/test/chatChannels.test.ts @@ -6,9 +6,10 @@ import { __makeChannelStreamEditorForTests, __makeChannelStreamTapForTests, } from "../src/v3/ai.js"; -import { simulateReadableStream, streamText } from "ai"; +import { simulateReadableStream, streamText, tool } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; function textStream(text: string) { const chunks: LanguageModelV3StreamPart[] = [ @@ -178,6 +179,107 @@ describe("chat.agent channels", () => { }); }); +describe("chat.agent channel interactions", () => { + function toolCallStream(toolCallId: string, toolName: string, input: unknown) { + return simulateReadableStream({ + chunks: [ + { type: "tool-input-start", id: toolCallId, toolName }, + { type: "tool-input-delta", id: toolCallId, delta: JSON.stringify(input) }, + { type: "tool-input-end", id: toolCallId }, + { type: "tool-call", toolCallId, toolName, input: JSON.stringify(input) }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 0, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }); + } + + it("resumes a pending tool from an interaction callback and finalizes the controls", async () => { + const TC = "tc_approve_1"; + const requestApproval = tool({ + description: "Request human approval before acting.", + inputSchema: z.object({ action: z.string() }), + }); + + let call = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: + call++ === 0 + ? toolCallStream(TC, "requestApproval", { action: "refund" }) + : textStream("refund issued"), + }), + }); + + const channel = recordingChannelConnector<{ text?: string; callback?: boolean }>({ + renderInteraction: (pending) => ({ text: `Approve ${pending[0]!.input!.action}?` }), + onInteraction: (event) => + event?.callback ? { toolCallId: TC, output: { approved: true } } : null, + }); + + const agent = chat.agent({ + id: "chatChannels.hitl-resolve", + channels: [channel], + run: async ({ messages, signal }) => + streamText({ model, messages, tools: { requestApproval }, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "chan-hitl-1" }); + try { + await harness.sendChannelEvent({ event: { text: "please refund", threadId: "chan-hitl-1" } }); + expect(channel.finalText()).toBe("Approve refund?"); + expect(channel.finalized).toHaveLength(0); + + await harness.sendChannelEvent({ event: { callback: true } }); + expect(channel.finalized).toHaveLength(1); + expect(channel.finalText()).toBe("refund issued"); + } finally { + await harness.close(); + } + }); + + it("drops a stale interaction callback and runs no turn", async () => { + let modelCalls = 0; + const model = new MockLanguageModelV3({ + doStream: async () => { + modelCalls += 1; + return { stream: textStream("should not run") }; + }, + }); + + const interactionEvents: unknown[] = []; + const channel = recordingChannelConnector<{ text?: string; callback?: boolean }>({ + onInteraction: (event) => { + interactionEvents.push(event); + return event?.callback ? { toolCallId: "does-not-exist", output: {} } : null; + }, + }); + + const agent = chat.agent({ + id: "chatChannels.hitl-stale", + channels: [channel], + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "chan-hitl-2" }); + try { + await harness.deliverChannelEvent({ event: { callback: true } }); + + expect(interactionEvents).toHaveLength(1); + expect(modelCalls).toBe(0); + expect(channel.sent).toHaveLength(0); + expect(channel.finalized).toHaveLength(0); + } finally { + await harness.close(); + } + }); +}); + describe("makeChannelStreamEditor", () => { function streamConnector(send: (message: { text: string }) => Promise<{ ref?: string }>) { return { delivery: "stream" as const, send } as never;