experimental-ash 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 9ac4a70: fix(slack): capture send once and await it inside the mention listener
8
+
9
+ The chat SDK defers mention handler invocation through the `waitUntil`
10
+ callback passed into `chat.webhooks.slack`, so the listener fires
11
+ after the route handler has returned. Per-request `send` therefore
12
+ cannot be threaded through any scope-based mechanism — by the time
13
+ the listener runs, the route's scope is gone and dispatches drop on
14
+ the floor (workflow never starts). The previous attempt to thread
15
+ `waitUntil(send)` through a per-request map also lost the dispatch:
16
+ the listener's `waitUntil(send)` push landed _after_ the dispatcher
17
+ had already snapshotted its `backgroundTasks` array into
18
+ `event.waitUntil(Promise.allSettled(backgroundTasks))`, so the send
19
+ promise was orphaned and the lambda could terminate mid-RPC.
20
+
21
+ `SendFn` is functionally identical across requests for a given
22
+ channel (`createSendFn` closes only over the bundled runtime, the
23
+ channel adapter, and the channel name — all stable). The channel now
24
+ captures `send` from the first request into a closure-scoped slot and
25
+ reuses it for every subsequent mention dispatch. The listener
26
+ `await`s send directly, so the chat SDK's outer dispatch IIFE awaits
27
+ the full send promise, which keeps the lambda alive through workflow
28
+ start under the request's `event.waitUntil`. No per-request lookup
29
+ map, no body parsing in the route handler, no request reconstruction
30
+ — the mention path is `if (!capturedSend) capturedSend = send; return
31
+ chat.webhooks.slack(req, { waitUntil })`.
32
+
33
+ Also refactors `slackChannel.ts` for readability: extracts the mention
34
+ listener and the interactive-payload handler as named file-scoped
35
+ helpers (`buildMentionListener`, `handleInteractionRequest`).
36
+ `slackChannel()` shrinks from ~316 lines to ~105 and reads
37
+ top-to-bottom — configure → `getChat` factory → `defineChannel` with
38
+ a route handler that branches interaction vs mention and otherwise
39
+ just forwards to the SDK.
40
+
41
+ ## 0.10.1
42
+
43
+ ### Patch Changes
44
+
45
+ - 05ab89c: fix(slack): eliminate duplicate replies on follow-up mentions in a thread
46
+
47
+ `slackChannel` registered its `chat.onNewMention` listener inside the route handler, but the chat SDK's `onNewMention` _pushes_ handlers to an internal list (it does not replace). On warm serverless workers the cached `chat` instance accumulated one listener per inbound webhook, so the N-th `app_mention` dispatched N independent agent turns. Symptom: the first mention in a thread replied once, the next replied twice, the next three times, etc. — reset only by a cold start.
48
+
49
+ Registration is now done once per `chat` instance inside `getChat()`. The route handler threads its per-request `send` / `waitUntil` to the listener through a per-`slackChannel` `AsyncLocalStorage` — `requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, ...))`. A naive shared module slot would race under concurrent webhook deliveries (the chat SDK yields the event loop on `await req.text()` inside `webhooks.slack`, so a second request can overwrite the slot before the first listener fires, causing request A's mention to dispatch with request B's `send`). ALS binds the store to the async call tree, so each concurrent route invocation gets its own isolated context.
50
+
51
+ The mention dispatch also now runs under `waitUntil(send(...).catch(log))` instead of `await send(...)`, mirroring the interaction path. This lets the webhook return `200 OK` within Slack's ~3s ACK window even on cold starts, avoiding the secondary duplication mode where Slack retries the same `app_mention` payload after a slow ACK.
52
+
3
53
  ## 0.10.0
4
54
 
5
55
  ### Minor Changes
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
6
6
  let cachedPackageInfo;
7
7
  // The package build stamps the published version into `dist` so bundled
8
8
  // deployments can still report package metadata without resolving package.json.
9
- const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.2";
10
10
  const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
11
11
  const WORKFLOW_MODULE_ALIASES = {
12
12
  "workflow/api": "src/compiled/@workflow/core/runtime.js",
@@ -5,7 +5,7 @@ import { createLogger } from "#internal/logging.js";
5
5
  import { buildSlackTurnMessage, collectSlackFileParts, createSlackFetchFile, } from "#public/channels/slack/attachments.js";
6
6
  import { deriveHitlResponse, isHitlAction, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js";
7
7
  import { mergeUploadPolicy } from "#public/channels/upload-policy.js";
8
- import { defineChannel, POST } from "#public/definitions/defineChannel.js";
8
+ import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
9
9
  const log = createLogger("slack.channel");
10
10
  function decodeThreadId(id) {
11
11
  const parts = id.replace(/^slack:/u, "").split(":");
@@ -135,17 +135,145 @@ function defaultInputRequestedHandler() {
135
135
  });
136
136
  };
137
137
  }
138
+ /**
139
+ * Build the once-registered `onNewMention` listener for a `Chat`
140
+ * instance. The chat SDK defers handler invocation through the
141
+ * `waitUntil` callback we pass into `webhooks.slack`, so the listener
142
+ * cannot rely on any per-request scope from the route handler. It
143
+ * uses `getSend` to look up the channel's captured `send` (which is
144
+ * functionally identical across requests — `createSendFn` closes only
145
+ * over the bundled runtime + adapter + channel name, all stable) and
146
+ * awaits it directly. The route's `event.waitUntil` keeps the lambda
147
+ * alive through the SDK's dispatch IIFE, which awaits this listener,
148
+ * which awaits send — so the workflow start completes before the
149
+ * lambda may terminate.
150
+ */
151
+ function buildMentionListener(config, uploadPolicy, getSend) {
152
+ return async (thread, message) => {
153
+ const raw = message.raw;
154
+ // Slack delivers `app_mention` and `message.channels` for the same
155
+ // utterance; only the former is the listener's job.
156
+ if (raw?.type !== "app_mention")
157
+ return;
158
+ const send = getSend();
159
+ if (!send) {
160
+ log.warn("slack mention received before any request captured send");
161
+ return;
162
+ }
163
+ const teamId = raw.team_id ?? raw.team;
164
+ const slackCtx = {
165
+ thread,
166
+ slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
167
+ };
168
+ const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
169
+ if (runOpts === null)
170
+ return;
171
+ if (config.onMention) {
172
+ try {
173
+ await config.onMention(slackCtx, message);
174
+ }
175
+ catch (error) {
176
+ log.error("onMention handler failed", { error });
177
+ }
178
+ }
179
+ const decoded = decodeThreadId(thread.id ?? "");
180
+ const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
181
+ const fileParts = collectSlackFileParts(message, uploadPolicy);
182
+ const turnMessage = buildSlackTurnMessage(message.text, fileParts);
183
+ try {
184
+ await send(turnMessage, {
185
+ auth: runOpts.auth,
186
+ continuationToken,
187
+ state: {
188
+ serializedThread: thread.toJSON(),
189
+ teamId: teamId ?? null,
190
+ },
191
+ });
192
+ }
193
+ catch (error) {
194
+ log.error("mention delivery failed", { error });
195
+ }
196
+ };
197
+ }
198
+ /**
199
+ * Handle a Slack `block_actions` interactive payload (button clicks,
200
+ * select changes, modal submits). HITL `ash_input:*` actions resume
201
+ * their paused session via `send`; non-HITL actions are forwarded to
202
+ * `config.onInteraction`. Always returns `Response("ok")` — dispatch
203
+ * runs under `waitUntil` so the webhook ACKs immediately.
204
+ */
205
+ async function handleInteractionRequest(rawBody, ctx, config) {
206
+ const ack = new Response("ok", { status: 200 });
207
+ const params = new URLSearchParams(rawBody);
208
+ const payloadStr = params.get("payload");
209
+ if (!payloadStr)
210
+ return ack;
211
+ let interaction;
212
+ try {
213
+ interaction = parseBlockActionsPayload(JSON.parse(payloadStr));
214
+ }
215
+ catch {
216
+ log.warn("failed to parse Slack interaction payload");
217
+ return ack;
218
+ }
219
+ if (!interaction)
220
+ return ack;
221
+ const continuationToken = `slack:${interaction.channelId}:${interaction.threadTs}`;
222
+ const inputResponses = interaction.actions
223
+ .map(deriveHitlResponse)
224
+ .filter((r) => r !== null);
225
+ if (inputResponses.length > 0) {
226
+ ctx.waitUntil(ctx
227
+ .send({ inputResponses }, {
228
+ auth: null,
229
+ continuationToken,
230
+ state: { serializedThread: null, teamId: interaction.teamId ?? null },
231
+ })
232
+ .catch((error) => {
233
+ log.error("HITL interaction delivery failed", { error });
234
+ }));
235
+ }
236
+ const onInteraction = config.onInteraction;
237
+ if (onInteraction) {
238
+ const customActions = interaction.actions.filter((a) => !isHitlAction(a.actionId));
239
+ if (customActions.length > 0) {
240
+ const chatModule = await import("#compiled/chat/index.js");
241
+ const thread = new chatModule.ThreadImpl({
242
+ adapterName: "slack",
243
+ channelId: interaction.channelId,
244
+ id: `slack:${interaction.channelId}:${interaction.threadTs}`,
245
+ isDM: false,
246
+ });
247
+ const slackCtx = {
248
+ thread,
249
+ slack: buildSlackApiHandle(thread, config.credentials?.botToken, interaction.teamId),
250
+ };
251
+ for (const action of customActions) {
252
+ ctx.waitUntil(Promise.resolve(onInteraction(action, slackCtx)).catch((error) => {
253
+ log.error("custom interaction handler failed", { error });
254
+ }));
255
+ }
256
+ }
257
+ }
258
+ return ack;
259
+ }
138
260
  export function slackChannel(config = {}) {
139
261
  const uploadPolicy = mergeUploadPolicy(config.uploadPolicy);
140
- const slackFetchFile = createSlackFetchFile({
141
- botToken: config.credentials?.botToken,
142
- });
262
+ const slackFetchFile = createSlackFetchFile({ botToken: config.credentials?.botToken });
143
263
  const stateAdapter = config.stateAdapter ?? createMemoryState();
264
+ const inputHandler = config.events?.["input.requested"] ?? defaultInputRequestedHandler();
265
+ // The chat SDK defers mention handler invocation past the route
266
+ // handler returning, so the listener can't read `send` off the
267
+ // current request. Capture it on the first request and reuse it —
268
+ // `createSendFn` closes only over the runtime + adapter + channel
269
+ // name, all stable across requests, so the captured instance works
270
+ // for every subsequent dispatch.
271
+ let capturedSend = null;
144
272
  let chatPromise = null;
145
273
  async function getChat() {
146
274
  if (chatPromise)
147
275
  return chatPromise;
148
- const promise = (async () => {
276
+ chatPromise = (async () => {
149
277
  const { botToken, signingSecret, webhookVerifier } = resolveSlackAdapterCredentials(config.credentials);
150
278
  if (!botToken) {
151
279
  throw new Error("slackChannel requires a bot token. Pass credentials.botToken or set SLACK_BOT_TOKEN.");
@@ -166,15 +294,17 @@ export function slackChannel(config = {}) {
166
294
  userName: config.botName ?? "ash-agent",
167
295
  });
168
296
  await chat.initialize();
297
+ // Register once per chat instance — `onNewMention` pushes
298
+ // handlers (does not replace), so registering per request would
299
+ // accumulate listeners on warm workers.
300
+ chat.onNewMention(buildMentionListener(config, uploadPolicy, () => capturedSend));
169
301
  return { chat };
170
302
  })();
171
- chatPromise = promise;
172
- promise.catch(() => {
303
+ chatPromise.catch(() => {
173
304
  chatPromise = null;
174
305
  });
175
- return promise;
306
+ return chatPromise;
176
307
  }
177
- const inputHandler = config.events?.["input.requested"] ?? defaultInputRequestedHandler();
178
308
  return defineChannel({
179
309
  state: { serializedThread: null, teamId: null },
180
310
  fetchFile: slackFetchFile,
@@ -184,102 +314,12 @@ export function slackChannel(config = {}) {
184
314
  routes: [
185
315
  POST(config.route ?? "/ash/v1/slack", async (req, { send, waitUntil }) => {
186
316
  const { chat } = await getChat();
317
+ if (!capturedSend)
318
+ capturedSend = send;
187
319
  const contentType = req.headers.get("content-type") ?? "";
188
320
  if (contentType.includes("application/x-www-form-urlencoded")) {
189
- const formData = await req.text();
190
- const params = new URLSearchParams(formData);
191
- const payloadStr = params.get("payload");
192
- if (payloadStr) {
193
- try {
194
- const payload = JSON.parse(payloadStr);
195
- const interaction = parseBlockActionsPayload(payload);
196
- if (interaction) {
197
- const continuationToken = `slack:${interaction.channelId}:${interaction.threadTs}`;
198
- const inputResponses = [];
199
- for (const action of interaction.actions) {
200
- const response = deriveHitlResponse(action);
201
- if (response !== null) {
202
- inputResponses.push(response);
203
- }
204
- }
205
- if (inputResponses.length > 0) {
206
- waitUntil(send({ inputResponses }, {
207
- auth: null,
208
- continuationToken,
209
- state: {
210
- serializedThread: null,
211
- teamId: interaction.teamId ?? null,
212
- },
213
- }).catch((error) => {
214
- log.error("HITL interaction delivery failed", { error });
215
- }));
216
- }
217
- if (config.onInteraction) {
218
- const customActions = interaction.actions.filter((a) => !isHitlAction(a.actionId));
219
- if (customActions.length > 0) {
220
- const chatModule = await import("#compiled/chat/index.js");
221
- const thread = new chatModule.ThreadImpl({
222
- adapterName: "slack",
223
- channelId: interaction.channelId,
224
- id: `slack:${interaction.channelId}:${interaction.threadTs}`,
225
- isDM: false,
226
- });
227
- const slackCtx = {
228
- thread,
229
- slack: buildSlackApiHandle(thread, config.credentials?.botToken, interaction.teamId),
230
- };
231
- for (const action of customActions) {
232
- waitUntil(Promise.resolve(config.onInteraction(action, slackCtx)).catch((error) => {
233
- log.error("custom interaction handler failed", { error });
234
- }));
235
- }
236
- }
237
- }
238
- }
239
- }
240
- catch {
241
- log.warn("failed to parse Slack interaction payload");
242
- }
243
- }
244
- return new Response("ok", { status: 200 });
321
+ return handleInteractionRequest(await req.text(), { send, waitUntil }, config);
245
322
  }
246
- chat.onNewMention(async (thread, message) => {
247
- const rawEvent = message.raw;
248
- // Slack sends both `app_mention` and `message.channels` for the same
249
- // utterance. The Chat SDK dedup relies on in-memory state that doesn't
250
- // survive serverless invocations, so both events reach this handler.
251
- // Only process `app_mention` to prevent duplicate runs.
252
- if (rawEvent?.type !== "app_mention")
253
- return;
254
- const teamId = rawEvent.team_id ?? rawEvent.team;
255
- const slackCtx = {
256
- thread,
257
- slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
258
- };
259
- const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
260
- if (runOpts === null)
261
- return;
262
- if (config.onMention) {
263
- try {
264
- await config.onMention(slackCtx, message);
265
- }
266
- catch (error) {
267
- log.error("onMention handler failed", { error });
268
- }
269
- }
270
- const decoded = decodeThreadId(thread.id ?? "");
271
- const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
272
- const fileParts = collectSlackFileParts(message, uploadPolicy);
273
- const turnMessage = buildSlackTurnMessage(message.text, fileParts);
274
- await send(turnMessage, {
275
- auth: runOpts.auth,
276
- continuationToken,
277
- state: {
278
- serializedThread: thread.toJSON(),
279
- teamId: teamId ?? null,
280
- },
281
- });
282
- });
283
323
  return await chat.webhooks.slack(req, { waitUntil });
284
324
  }),
285
325
  ],
@@ -299,10 +339,7 @@ export function slackChannel(config = {}) {
299
339
  return send(input.message, {
300
340
  auth: input.auth,
301
341
  continuationToken: `slack:${channelId}:`,
302
- state: {
303
- serializedThread: thread.toJSON(),
304
- teamId: null,
305
- },
342
+ state: { serializedThread: thread.toJSON(), teamId: null },
306
343
  });
307
344
  },
308
345
  events: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"