Skip to content

Commit d80e277

Browse files
committed
feat(webhooks): channel egress (reply round-trip, final mode)
Close the channel loop: an agent turn triggered by a channel delivery now posts its reply back to the channel. In "final" mode the framework posts an ack placeholder at turn start, then edits it to the answer at turn complete. The connector gains the egress half: outbound (map the turn's reply to a channel message, or null to post nothing) and send (post/edit, provider-specific). The framework owns the delivery mode, the message-ref threading, and (for the future stream mode) the debounce. A new generic chat.channels.custom lets you supply your own send for non-Slack surfaces; chat.channels.slack's real send is the @trigger.dev/slack package. SDK-only: egress runs in-run (it needs the run's reply and channel context), driven from the turn boundaries. A manual-pipe turn has no captured response, so it opts out. delivery defaults to "final"; token-level "stream" (debounced live edits) is a fast-follow. Unreleased feature, so no released-API impact.
1 parent 2975dd6 commit d80e277

1 file changed

Lines changed: 147 additions & 8 deletions

File tree

  • packages/trigger-sdk/src/v3

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ import type {
5757
WebhookVerifierArtifact,
5858
WebhookSecretProvisioning,
5959
WebhookHmacConfig,
60+
AnyWebhookSource,
61+
InferWebhookEvent,
6062
} from "@trigger.dev/core/v3";
6163
import { chatEvent, normalizeKeyString } from "./webhooks.js";
6264
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
@@ -4596,8 +4598,35 @@ export type ChatResumeEvent<TClientData = unknown, TUIM extends UIMessage = UIMe
45964598
// A connector registers an agent-scoped endpoint that delivers each verified event as a channel
45974599
// message; the run applies inbound() to the raw event to get the turn's message. The loop guard is the
45984600
// server-side `filter` (an ignored event never becomes a delivery), so inbound is a pure mapper.
4601+
// The reply round-trips (egress) in-run: outbound() maps the turn's reply to a channel message, send()
4602+
// posts/edits it. delivery "final" posts an ack at turn start then edits it to the answer at turn end.
45994603
export type ChannelMessageInput = string | UIMessage;
46004604

4605+
// The channel message a provider posts. Framework requires `text`; providers extend (Slack adds blocks).
4606+
export type ChannelMessage = { text: string; [key: string]: unknown };
4607+
4608+
// What outbound() receives: enough to decide what (or whether, null) to post. For "stream", `text` is
4609+
// the accumulated text so far.
4610+
export type ChannelReply = {
4611+
text: string;
4612+
message: UIMessage;
4613+
final: boolean;
4614+
stopped: boolean;
4615+
error?: unknown;
4616+
};
4617+
4618+
export type ChannelSendCtx<TEvent = unknown> = {
4619+
event: TEvent;
4620+
deliveryId: string;
4621+
previousRef?: string;
4622+
mode: "final" | "stream";
4623+
final: boolean;
4624+
};
4625+
4626+
// Reserved for 2c: resolve a provider credential keyed by the incoming event's installation (team_id),
4627+
// not the connector (one connector serves many Slack workspaces). The in-run send() calls it at post time.
4628+
export type ResolveChannelToken<TEvent = unknown> = (event: TEvent) => Promise<string>;
4629+
46014630
declare const channelEventPhantom: unique symbol;
46024631

46034632
export interface ChannelConnector<TEvent = unknown> {
@@ -4608,7 +4637,11 @@ export interface ChannelConnector<TEvent = unknown> {
46084637
secretProvisioning?: WebhookSecretProvisioning;
46094638
filter?: string;
46104639
inbound: (event: TEvent) => ChannelMessageInput;
4611-
delivery: "stream" | "final"; // egress mode (used by the reply round-trip; a later phase)
4640+
// Egress (optional; a channel can be inbound-only). Fires only when `send` is set.
4641+
outbound?: (reply: ChannelReply) => ChannelMessage | null;
4642+
ack?: (event: TEvent) => ChannelMessage | null; // placeholder posted at turn start ("final" mode)
4643+
send?: (message: ChannelMessage, ctx: ChannelSendCtx<TEvent>) => Promise<{ ref?: string }>;
4644+
delivery: "final" | "stream"; // "final" (ack + edit) is v1; "stream" (debounced edits) is a fast-follow
46124645
readonly [channelEventPhantom]?: TEvent;
46134646
}
46144647
export type AnyChannelConnector = ChannelConnector<any>;
@@ -4656,11 +4689,14 @@ export function slackChannel<
46564689
id: string;
46574690
key: ValidatedWebhookKey<TEvent, TKey>;
46584691
inbound: (event: TEvent) => ChannelMessageInput;
4692+
outbound?: (reply: ChannelReply) => ChannelMessage | null;
46594693
filter?: TFilter & ValidateWebhookFilter<TEvent, TFilter>;
4660-
delivery?: "stream" | "final";
4694+
delivery?: "final" | "stream";
46614695
}): ChannelConnector<TEvent> {
4662-
const { id, key, inbound, filter, delivery } = options;
4696+
const { id, key, inbound, outbound, filter, delivery } = options;
46634697
resourceCatalog.registerDeclaredSessionWebhook(id);
4698+
// `send` is intentionally absent here: the real Slack sender (bot token + chat.postMessage/update)
4699+
// is the @trigger.dev/slack provider package. Until then a Slack channel is inbound-only.
46644700
return {
46654701
id,
46664702
source: "slack",
@@ -4669,13 +4705,56 @@ export function slackChannel<
46694705
secretProvisioning: "integrator",
46704706
filter,
46714707
inbound,
4672-
delivery: delivery ?? "stream",
4708+
outbound,
4709+
delivery: delivery ?? "final",
46734710
} as ChannelConnector<TEvent>;
46744711
}
46754712

4713+
/**
4714+
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
4715+
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
4716+
* control. Use for providers without a preset, or to test the channel round-trip end to end.
4717+
*/
4718+
export function chatChannelCustom<
4719+
TSource extends AnyWebhookSource,
4720+
const TKey extends string = string,
4721+
const TFilter extends string = string,
4722+
>(options: {
4723+
id: string;
4724+
source: TSource;
4725+
key: ValidatedWebhookKey<InferWebhookEvent<TSource>, TKey>;
4726+
inbound: (event: InferWebhookEvent<TSource>) => ChannelMessageInput;
4727+
outbound?: (reply: ChannelReply) => ChannelMessage | null;
4728+
ack?: (event: InferWebhookEvent<TSource>) => ChannelMessage | null;
4729+
send?: (
4730+
message: ChannelMessage,
4731+
ctx: ChannelSendCtx<InferWebhookEvent<TSource>>
4732+
) => Promise<{ ref?: string }>;
4733+
filter?: TFilter & ValidateWebhookFilter<InferWebhookEvent<TSource>, TFilter>;
4734+
delivery?: "final" | "stream";
4735+
}): ChannelConnector<InferWebhookEvent<TSource>> {
4736+
const { id, source, key, inbound, outbound, ack, send, filter, delivery } = options;
4737+
resourceCatalog.registerDeclaredSessionWebhook(id);
4738+
return {
4739+
id,
4740+
source: source.provider,
4741+
key: normalizeKeyString(key as string),
4742+
verifierArtifact: source.verifier,
4743+
secretProvisioning: source.secretProvisioning,
4744+
filter,
4745+
inbound,
4746+
outbound,
4747+
ack,
4748+
send,
4749+
delivery: delivery ?? "final",
4750+
} as ChannelConnector<InferWebhookEvent<TSource>>;
4751+
}
4752+
46764753
const chatChannels = {
46774754
/** Slack as a chat frontend. See {@link slackChannel}. */
46784755
slack: slackChannel,
4756+
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
4757+
custom: chatChannelCustom,
46794758
};
46804759

46814760
// Turn a channel connector's inbound() result into a user UIMessage for the turn.
@@ -4684,6 +4763,18 @@ function toUserUIMessage(input: ChannelMessageInput, messageId: string): UIMessa
46844763
return { id: messageId, role: "user", parts: [{ type: "text", text: input }] } as UIMessage;
46854764
}
46864765

4766+
// Sum the text parts of a UIMessage (the reply text egress posts).
4767+
function uiMessageText(message: UIMessage): string {
4768+
return (message.parts ?? [])
4769+
.map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
4770+
.join("");
4771+
}
4772+
4773+
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
4774+
function defaultChannelOutbound(reply: ChannelReply): ChannelMessage | null {
4775+
return reply.text ? { text: reply.text } : null;
4776+
}
4777+
46874778
// The `action` type onAction receives: the actionSchema output (when set) unioned with the action
46884779
// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses
46894780
// cleanly when no events are listed; `unknown` is kept only when neither source is present.
@@ -6511,15 +6602,37 @@ function chatAgent<
65116602
void _hsm;
65126603
// Channel-delivered turn: map the raw provider event to the turn's message via the
65136604
// connector's inbound(), resolved by connectorId. The loop guard is the connector's
6514-
// server-side filter, so inbound is a pure mapper here.
6605+
// server-side filter, so inbound is a pure mapper here. `channelConn` + `channelAckRef`
6606+
// are read again at turn-complete to egress the reply.
65156607
let effectiveIncomingMessage = incomingMessage;
6608+
let channelConn: AnyChannelConnector | undefined;
6609+
let channelAckRef: string | undefined;
65166610
if (wireChannelEvent) {
6517-
const connector = channels?.find((c) => c.id === wireChannelEvent.connectorId);
6518-
if (connector) {
6611+
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
6612+
if (channelConn) {
65196613
effectiveIncomingMessage = toUserUIMessage(
6520-
connector.inbound(wireChannelEvent.event),
6614+
channelConn.inbound(wireChannelEvent.event),
65216615
currentWirePayload.messageId ?? wireChannelEvent.deliveryId
65226616
) as typeof incomingMessage;
6617+
// "final" egress: post an ack placeholder now so a message ref exists to edit at turn end.
6618+
if (channelConn.send && channelConn.ack) {
6619+
const ackMessage = channelConn.ack(wireChannelEvent.event);
6620+
if (ackMessage) {
6621+
try {
6622+
const ackResult = await channelConn.send(ackMessage, {
6623+
event: wireChannelEvent.event,
6624+
deliveryId: wireChannelEvent.deliveryId,
6625+
mode: channelConn.delivery,
6626+
final: false,
6627+
});
6628+
channelAckRef = ackResult?.ref;
6629+
} catch (ackError) {
6630+
logger.warn("chat.agent: channel ack post failed; continuing", {
6631+
error: ackError,
6632+
});
6633+
}
6634+
}
6635+
}
65236636
}
65246637
}
65256638
const incomingMessages: TUIMessage[] = effectiveIncomingMessage
@@ -7744,6 +7857,32 @@ function chatAgent<
77447857
turnAccessToken
77457858
);
77467859

7860+
// Channel egress ("final"): map the turn's reply via outbound() and send it, editing
7861+
// the ack placeholder posted at turn start (previousRef). Best-effort: an egress failure
7862+
// is logged, not fatal. A manual-pipe turn has no responseMessage, so it opts out.
7863+
if (wireChannelEvent && channelConn?.send && turnCompleteEvent.responseMessage) {
7864+
const outbound = channelConn.outbound ?? defaultChannelOutbound;
7865+
const channelMessage = outbound({
7866+
text: uiMessageText(turnCompleteEvent.responseMessage),
7867+
message: turnCompleteEvent.responseMessage,
7868+
final: true,
7869+
stopped: turnCompleteEvent.stopped,
7870+
});
7871+
if (channelMessage) {
7872+
try {
7873+
await channelConn.send(channelMessage, {
7874+
event: wireChannelEvent.event,
7875+
deliveryId: wireChannelEvent.deliveryId,
7876+
previousRef: channelAckRef,
7877+
mode: channelConn.delivery,
7878+
final: true,
7879+
});
7880+
} catch (egressError) {
7881+
logger.warn("chat.agent: channel egress send failed", { error: egressError });
7882+
}
7883+
}
7884+
}
7885+
77477886
// Fire onTurnComplete — stream is closed, use for persistence.
77487887
if (onTurnComplete) {
77497888
await tracer.startActiveSpan(

0 commit comments

Comments
 (0)