Skip to content

Commit 3a39054

Browse files
committed
feat(webhooks): channel egress stream mode (live debounced edits)
Implement delivery: "stream" for channel egress. Instead of one edit at turn complete ("final"), the framework tees the reply stream and debounce-edits the ack message as tokens arrive, so the reply grows live in the thread. Trailing-edge, one edit per interval (Slack chat.update is ~1/s); the turn-complete final edit remains the authoritative last write. Streaming needs an ack (a message ref to edit); without one it degrades to a single final edit. Best-effort: an edit failure is logged, not fatal. SDK-only (the run loop's run() output pipe gains a channel tee, gated on delivery === "stream"); non-stream and non-channel turns pipe unchanged. Unreleased feature, so no released-API impact.
1 parent b927b15 commit 3a39054

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

.changeset/webhook-channels.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"trigger.dev": patch
66
---
77

8-
Add channels: chat frontends for agents. A `chat.agent` can now take `channels: [...]` alongside `events: [...]`. A channel is a bidirectional chat surface (Slack, etc.): a verified inbound event is routed to a durable per-key session and run as a turn (via the connector's `inbound()` mapper), and the agent's reply is posted back to the surface (`outbound()` + `send()`), so a thread is a real conversation with the agent. Egress runs in-run; "final" mode posts an ack placeholder at turn start and edits it to the answer at turn complete.
8+
Add channels: chat frontends for agents. A `chat.agent` can now take `channels: [...]` alongside `events: [...]`. A channel is a bidirectional chat surface (Slack, etc.): a verified inbound event is routed to a durable per-key session and run as a turn (via the connector's `inbound()` mapper), and the agent's reply is posted back to the surface (`outbound()` + `send()`), so a thread is a real conversation with the agent. Egress runs in-run with two delivery modes: "final" (default) posts an ack placeholder at turn start and edits it to the answer at turn complete; "stream" debounce-edits the message live as the reply streams.
99

1010
`chat.channels.custom({ source, key, inbound, outbound, send, ack, filter, delivery })` is the generic connector (bring your own source and egress). The new `@trigger.dev/slack` package ships `slack({ token, ... })`: the Slack Events API verifier, the `url_verification` handshake, a per-thread session key (with a `thread_ts || ts` fallback for thread starts), the real `chat.postMessage`/`chat.update` egress, and a mandatory self-message filter so the bot never replies to its own posts.
1111

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

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4708,6 +4708,70 @@ function defaultChannelOutbound(reply: ChannelReply): ChannelMessage | null {
47084708
return reply.text ? { text: reply.text } : null;
47094709
}
47104710

4711+
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
4712+
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
4713+
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
4714+
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
4715+
function makeChannelStreamEditor<TEvent>(
4716+
connector: ChannelConnector<TEvent>,
4717+
channelEvent: { event: unknown; deliveryId: string },
4718+
ackRef: string
4719+
) {
4720+
const outbound = connector.outbound ?? defaultChannelOutbound;
4721+
let latest = "";
4722+
let timer: ReturnType<typeof setTimeout> | undefined;
4723+
let inFlight = false;
4724+
let stopped = false;
4725+
4726+
const edit = async () => {
4727+
if (stopped || inFlight) return;
4728+
const text = latest;
4729+
const message = outbound({
4730+
text,
4731+
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage,
4732+
final: false,
4733+
stopped: false,
4734+
});
4735+
if (!message) return;
4736+
inFlight = true;
4737+
try {
4738+
await connector.send!(message, {
4739+
event: channelEvent.event as TEvent,
4740+
deliveryId: channelEvent.deliveryId,
4741+
previousRef: ackRef,
4742+
mode: "stream",
4743+
final: false,
4744+
});
4745+
} catch (error) {
4746+
logger.warn("chat.agent: channel stream edit failed", { error });
4747+
} finally {
4748+
inFlight = false;
4749+
}
4750+
};
4751+
4752+
return {
4753+
observe(chunk: unknown) {
4754+
if (stopped) return;
4755+
const c = chunk as { type?: string; delta?: unknown };
4756+
if (c?.type === "text-delta" && typeof c.delta === "string") {
4757+
latest += c.delta;
4758+
if (!timer)
4759+
timer = setTimeout(() => {
4760+
timer = undefined;
4761+
void edit();
4762+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
4763+
}
4764+
},
4765+
stop() {
4766+
stopped = true;
4767+
if (timer) {
4768+
clearTimeout(timer);
4769+
timer = undefined;
4770+
}
4771+
},
4772+
};
4773+
}
4774+
47114775
// The `action` type onAction receives: the actionSchema output (when set) unioned with the action
47124776
// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses
47134777
// cleanly when no events are listed; `unknown` is kept only when neither source is present.
@@ -7358,7 +7422,31 @@ function chatAgent<
73587422
resolveOnFinish!();
73597423
},
73607424
});
7361-
await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" });
7425+
// Channel egress "stream" mode: tee the output and debounce-edit the ack message
7426+
// as the reply streams. Needs the ack ref to edit; the final edit at turn-complete
7427+
// is the authoritative last write. Non-stream / non-channel turns pipe unchanged.
7428+
let streamForPipe: typeof uiStream = uiStream;
7429+
if (
7430+
wireChannelEvent &&
7431+
channelConn?.send &&
7432+
channelConn.delivery === "stream" &&
7433+
channelAckRef &&
7434+
uiStream instanceof ReadableStream
7435+
) {
7436+
const editor = makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef);
7437+
streamForPipe = uiStream.pipeThrough(
7438+
new TransformStream({
7439+
transform(chunk, controller) {
7440+
editor.observe(chunk);
7441+
controller.enqueue(chunk);
7442+
},
7443+
flush() {
7444+
editor.stop();
7445+
},
7446+
})
7447+
);
7448+
}
7449+
await pipeChat(streamForPipe, { signal: combinedSignal, spanName: "stream response" });
73627450
}
73637451
} catch (error) {
73647452
// Handle AbortError from streamText gracefully

0 commit comments

Comments
 (0)