feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop - #4537
feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop#4537ericallam wants to merge 31 commits into
Conversation
🦋 Changeset detectedLatest commit: 693c646 The changes in this PR will be included in the next version bump. This PR includes changesets to release 30 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan includes up to 10 reviews per rolling hour; 8 remain after this review. 📜 Recent review details⏰ Context from checks skipped due to timeout. (37)
WalkthroughAdded typed webhook sources, provider verification, webhook tasks, and durable 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
b4b9f89 to
274c3ab
Compare
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/slack
@trigger.dev/sdk
commit: |
8f0664d to
0a23edf
Compare
38468bb to
2718f32
Compare
39c0585 to
0debb64
Compare
…fter one decision defaultSlackFinalizeInteraction re-rendered the message by dropping every actions block, so once one tool call was approved or denied the other pending tool calls in the same message lost their buttons and their turn could not resume. Drop only the resolved tool call's actions block (matched by the toolCallId:: button value prefix) and keep the rest.
…rl-secret warning The typed-event example now passes webhooks.stripe<Stripe.Event>() to webhook() so it actually demonstrates typed access to event. The verification section no longer claims every idempotency key comes from a provider event id (it documents the raw-body/timestamp/signature fallback), and adds a warning that url-secret exposes the secret in the URL.
…st harness sendChannelEvent/deliverChannelEvent default connectorId to DEFAULT_TEST_CONNECTOR_ID, not to a sole configured connector's id as the previous doc implied. Describe the actual default (which lines up with recordingChannelConnector's own default id).
…channel interaction A duplicate or late channel interaction (e.g. a repeated Slack approval click) whose toolCallId matches no pending tool call was dropped without detaching the per-turn session.in listener. That listener marks inbound records CONSUMED, so after the drop the next user message was swallowed into pendingWireMessages (already drained before the wait) and the turn idled until the timeout, ending the run and losing the message. Mirror the action path: detach msgSub and decrement turn (a dropped interaction is not a turn) before falling through to the wait.
…l error callbacks Two channel-connector fixes in the chat.agent turn loop: - Under a pendingMessages-enabled agent, the mid-turn session.in handler returned early for any record without a steerable `.message`. Channel deliveries carry `channelEvent` and no `.message`, so an inbound Slack message or block_actions approval click arriving mid-turn was neither steered nor buffered, and never became a turn. Channel records now fall through to pendingWireMessages so they dispatch as the next turn. - In the turn error handler, resolveReactionChoice (which invokes the user's reactions.error callback) and channelConn.outbound ran unguarded. A throw escaped the catch, skipping the error chunk, turn-complete, and wait-for-next-message, killing the whole conversation. Both are now wrapped so a bad user callback degrades to a warn.
…eMessages agents A channel interaction (e.g. a Slack approval click) is resolved at the top of the turn by matching its toolCallId against the in-memory accumulator. For agents that register hydrateMessages, the accumulator is empty on a fresh continuation / OOM-retry boot (those agents own persistence, so the boot seeding block is skipped and the hydrate hook only runs later in the turn). The click therefore matched nothing, was classified as a stale interaction, and was dropped, losing the human's decision and leaving the paused turn unresolved. When an interaction arrives and hydrateMessages is registered but the accumulator is empty, load the persisted chain via the hydrate hook first so the interaction can find its pending tool call. The per-turn hydrate later in the turn still runs to persist the synthesized resolution message. Guarded on an empty accumulator so warm turns don't re-hydrate.
…l error egress - buildInteractionResolutionMessage matched a tool part by toolCallId without checking it was still input-available, so a duplicate or retried approval/deny click on an already-answered tool call built a fresh resolution and ran the decision a second time. Gate on state === 'input-available' so a resolved call resolves to a dropped stale interaction, matching the function's documented contract. - The turn-error channel egress only edited the start-of-turn ack placeholder, so a connector configured with ack: null posted nothing on a failed turn (the user saw silence). Send a fresh final message when there is no placeholder ref. The error text is now run through the agent's onError option (same sanitizer the browser stream uses) instead of forwarding turnError.message verbatim to the channel surface.
…'t hit a TDZ The channel-interaction pre-hydrate added for hydrateMessages agents referenced clientData, but clientData was declared with const later in the same turn block. The early reference hit the temporal dead zone and threw ReferenceError, which the local try/catch swallowed, so hydration silently never ran and the button click was still dropped as a stale interaction. Parse clientData once above the channel-event block so both the interaction hydrate and the rest of the turn share the same value.
…acing, don't clobber posted answers
- channelReplyText selected parts with 'text' in p, which also matches AI
SDK reasoning parts ({ type: 'reasoning', text }). With sendReasoning
defaulting to true, a reasoning-capable model's private chain-of-thought
was concatenated into the text posted to Slack. Narrowed the selection to
parts whose type is exactly 'text'.
- normalizeKeyString namespaced only the whole placeholder, so a fallback
key like {a || b} (the Slack connector's DEFAULT_KEY form) left every
alternative after the first bare, diverging from ValidatedWebhookKey which
validates each || side and defaults bare sides to the body. It now splits
on ||, trims, and namespaces each alternative. Added test cases.
- On the turn-error path the error text was sent with previousRef pointing
at the ack placeholder. If a hook after the final answer was posted threw
(e.g. onTurnComplete), the delivered answer was edited into the error
string. Track whether the final answer was posted and, if so, send the
error as a fresh follow-up instead of editing over the answer.
…ll is absent; unspace Slack guard in-list - The channel-interaction pre-hydrate was gated on an empty accumulator, so a hydrate-backed agent whose in-memory accumulator was non-empty but no longer held the pending tool part (after a chat.history mutation or a compaction trim) would skip the hydrate and drop the click as stale. Gate instead on the pending toolCallId being absent from the accumulator, so the persisted chain is reloaded whenever the call can't be found (a duplicate click on an already-resolved call still short-circuits without a needless hydrate, since the call is present). - Unspace the Slack SELF_MESSAGE_GUARD in-list to match the documented filter style. The runtime parser already skips whitespace so this is cosmetic, but it keeps the guard consistent with the docs example.
…, skip unroutable channel turns
- webhooks.gitlab used the Svix preset (svix-id/svix-timestamp/svix-signature,
HMAC over {id}.{timestamp}.{body}), but GitLab doesn't sign bodies: it echoes
the configured token in X-Gitlab-Token. Every GitLab delivery failed
verification. Switched it to the shared-secret scheme (header, x-gitlab-token).
- The Slack connector declared secretProvisioning 'integrator', so the Connect
panel offered a Generate-secret button, but Slack mints its own Signing Secret
that must be pasted (core already classifies slack as 'provider'). Changed to
'provider'.
- A verified channel delivery naming a connector the running agent doesn't have
fell through to a normal turn with an empty incoming message, so the agent
answered nothing and burned tokens. Handle the unresolved-connector case like a
stale interaction: warn with the connectorId/deliveryId and skip the turn
without consuming a turn.
…ocks A text-only reply issued chat.update with text alone, so Slack kept whatever blocks were already on the message (e.g. approval controls previously rendered into the same ts). Send blocks: [] on edits when the outgoing message has no blocks, so an edit is authoritative. postMessage still omits blocks when empty.
…ai.ts formatting The SELF_MESSAGE_GUARD in-list was unspaced earlier to match the documented filter style, but its unit test still asserted the spaced substring. Update the assertion. Also apply oxfmt to a wrapped line in the channel error-reaction block that exceeded the width limit.
…n't fail a delivered turn The turn-start 'working' resolver and the success-path 'done' resolver both awaited user-supplied reactions.working / reactions.done callbacks unguarded. A rejection propagated into the turn catch, which then posted an error to the channel and skipped onTurnComplete / the snapshot write, turning an already-delivered answer into a reported failure. Wrapped both in try/catch that degrade to a warn, matching the error-path reaction handling.
The generic webhooks.slack() SDK source was auto-generated from the core provider config, which carries only the HMAC verifier. Slack additionally needs the one-time url_verification handshake (before an Event Subscriptions URL can be saved) and form-encoded interactivity parsing, so an endpoint built from webhooks.slack() could never be connected. Drop slack from the generated SDK producers (runtime + type) and export webhookSource() from @trigger.dev/slack, which carries the handshake + formPayload the connector already uses. Users get a plain Slack webhook source from webhookSource(), or the full chat channel from slack(). Core's slack config stays for server-side verification.
…ress In stream delivery the debounce editor's stop() cleared the pending timer but returned immediately even with an edit request already in flight. The turn then issued the authoritative final edit to the same message ref, so a slower in-flight partial-text edit could land after it and leave truncated text as the last write. stop() now awaits any in-flight edit (and makeChannelStreamTap's flush/cancel and the turn finally await stop()), so the final edit is always the last write.
Summary
The public SDK and docs half of hosted webhooks:
webhook()with typed provider sources (webhooks.stripe(),webhooks.github(),webhooks.svix(), and more, pluswebhooks.custom<T>()),chat.eventandchat.channelsfor agent channels, human-in-the-loop tool approvals, the new@trigger.dev/slackconnector, and the webhooks docs section.Stacked on the server PR
This is the top of a stack. Its base is #4344 (the server half: ingress, delivery pipeline, dashboard, and the shared
@trigger.dev/coreschemas this SDK builds on), so the diff here is API-only and it builds against a base that already has core.The single changeset in this PR bumps
@trigger.dev/core,@trigger.dev/sdk,@trigger.dev/slack, andtrigger.devtogether, so core (whose code lands via #4344) is published alongside the SDK.Merge order
Merges after #4344. The plan: land and deploy the server behind its flag, cut prerelease (rc) packages for early users to test against the live environment, then merge this and cut the real release once the feature is live. When #4344 merges, GitHub retargets this PR's base to
mainautomatically.