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.
|
|
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
|
-
|
|
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
|
|
172
|
-
promise.catch(() => {
|
|
303
|
+
chatPromise.catch(() => {
|
|
173
304
|
chatPromise = null;
|
|
174
305
|
});
|
|
175
|
-
return
|
|
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
|
-
|
|
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: {
|