experimental-ash 0.9.0 → 0.10.1

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,29 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 05ab89c: fix(slack): eliminate duplicate replies on follow-up mentions in a thread
8
+
9
+ `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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ ## 0.10.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 5bc39de: feat(slack): pluggable `stateAdapter` for production persistence
20
+
21
+ `slackChannel` and `slack()` now accept a `stateAdapter` option implementing the chat SDK `StateAdapter` contract. Used for callback URLs on posted cards, `thread.state` / `thread.setState`, `thread.subscribe`, message queues, and locks. Defaults to an in-memory adapter (development only — loses state on restart, not shared across processes). Pass a Redis/Postgres-backed adapter for production.
22
+
23
+ The supplied instance is shared across the inbound webhook path (`Chat` constructor) and every event-handler context rebuild, so state stays coherent within a single agent run. The default in-memory adapter is now constructed once at channel-construction time and shared the same way, replacing the previous behavior where the inbound path and each rebuild call each got their own isolated memory adapter.
24
+
25
+ A new exported type alias `SlackStateAdapter` aliases the chat SDK's `StateAdapter` interface.
26
+
3
27
  ## 0.9.0
4
28
 
5
29
  ### Minor Changes
@@ -169,6 +169,29 @@ export default slackChannel({
169
169
  (`turn.started`, `message.completed`, `session.failed`, etc.). They run inside the workflow context,
170
170
  not on the inbound webhook side.
171
171
 
172
+ ## State Persistence
173
+
174
+ The Slack channel uses a state backend for callback URLs on posted cards, `thread.state` /
175
+ `thread.setState`, `thread.subscribe`, message queues, and locks. By default this is an in-memory
176
+ adapter scoped to the current process, which loses state on restart and is not coherent across
177
+ processes. For deployed agents, pass a production adapter (Redis, Postgres, etc.) implementing the
178
+ chat SDK `StateAdapter` contract. Construct it once at module top-level so all requests share one
179
+ connection pool:
180
+
181
+ ```ts
182
+ import { createRedisState } from "@chat-adapter/state-redis";
183
+ import { slack } from "experimental-ash/channels/slack";
184
+
185
+ const stateAdapter = createRedisState({ url: process.env.REDIS_URL! });
186
+
187
+ export default slack({
188
+ stateAdapter,
189
+ });
190
+ ```
191
+
192
+ The same instance is reused across the inbound webhook path and every event-handler context rebuild,
193
+ so state stays coherent within a single agent run.
194
+
172
195
  ## Typing Indicators
173
196
 
174
197
  Out of the box, `slack()` posts typing statuses so the user sees feedback before the agent starts
@@ -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.9.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.1";
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",
@@ -1,4 +1,4 @@
1
1
  export { slack, type SlackOptions } from "#public/channels/slack/slack.js";
2
- export { slackChannel, type SlackApiHandle, type SlackApiResponse, type SlackChannel, type SlackChannelConfig, type SlackChannelEvents, type SlackChannelCredentials, type SlackChannelState, type SlackContext, type SlackInteractionAction, type SlackReceiveArgs, type SlackWebhookVerifier, } from "#public/channels/slack/slackChannel.js";
2
+ export { slackChannel, type SlackApiHandle, type SlackApiResponse, type SlackChannel, type SlackChannelConfig, type SlackChannelEvents, type SlackChannelCredentials, type SlackChannelState, type SlackContext, type SlackInteractionAction, type SlackReceiveArgs, type SlackStateAdapter, type SlackWebhookVerifier, } from "#public/channels/slack/slackChannel.js";
3
3
  export { Actions, Button, Card, CardText, Divider, Fields, Image, LinkButton, Modal, RadioSelect, Section, Select, SelectOption, Table, TextInput, } from "#compiled/chat/index.js";
4
4
  export type { AdapterPostableMessage, Attachment, Author, CardElement, FileUpload, Message, PostableMessage, SentMessage, Thread, } from "#compiled/chat/index.js";
@@ -1,9 +1,10 @@
1
1
  import type { SessionAuthContext } from "#channel/types.js";
2
2
  import type { Channel } from "#public/definitions/defineChannel.js";
3
- import { type SlackChannelCredentials, type SlackChannelState } from "#public/channels/slack/slackChannel.js";
3
+ import { type SlackChannelCredentials, type SlackChannelState, type SlackStateAdapter } from "#public/channels/slack/slackChannel.js";
4
4
  export interface SlackOptions {
5
5
  readonly auth?: (message: import("#compiled/chat/index.js").Message) => SessionAuthContext | null;
6
6
  readonly credentials?: SlackChannelCredentials;
7
7
  readonly botName?: string;
8
+ readonly stateAdapter?: SlackStateAdapter;
8
9
  }
9
10
  export declare function slack(options?: SlackOptions): Channel<SlackChannelState>;
@@ -15,6 +15,7 @@ export function slack(options = {}) {
15
15
  return slackChannel({
16
16
  credentials: options.credentials,
17
17
  botName: options.botName,
18
+ stateAdapter: options.stateAdapter,
18
19
  run(_ctx, message) {
19
20
  return { auth: resolveAuth(message) };
20
21
  },
@@ -1,5 +1,5 @@
1
1
  import { type SlackBotToken } from "#compiled/@chat-adapter/slack/index.js";
2
- import { type Message, type SerializedThread, type Thread } from "#compiled/chat/index.js";
2
+ import { type Message, type SerializedThread, type StateAdapter, type Thread } from "#compiled/chat/index.js";
3
3
  import type { HandleMessageStreamEvent } from "#protocol/message.js";
4
4
  import { type UploadPolicy } from "#public/channels/upload-policy.js";
5
5
  import { type Channel } from "#public/definitions/defineChannel.js";
@@ -36,6 +36,14 @@ export interface SlackChannelState {
36
36
  * with Vercel OIDC instead of Slack's signing secret.
37
37
  */
38
38
  export type SlackWebhookVerifier = (request: Request, body: string) => unknown | Promise<unknown>;
39
+ /**
40
+ * State backend used by the chat SDK for callback URL persistence on
41
+ * posted cards, `thread.state`/`setState`, `thread.subscribe`, message
42
+ * queues, and locks. Any value implementing the chat SDK
43
+ * `StateAdapter` contract works — `@chat-adapter/state-memory` for
44
+ * local development, a Redis/Postgres-backed adapter for production.
45
+ */
46
+ export type SlackStateAdapter = StateAdapter;
39
47
  export interface SlackChannelCredentials {
40
48
  readonly botToken?: SlackBotToken;
41
49
  /**
@@ -93,6 +101,17 @@ export interface SlackChannelEvents {
93
101
  export interface SlackChannelConfig {
94
102
  readonly credentials?: SlackChannelCredentials;
95
103
  readonly botName?: string;
104
+ /**
105
+ * State backend for chat SDK persistence — callback URLs on posted
106
+ * cards, `thread.state` / `thread.setState`, `thread.subscribe`,
107
+ * message queues, locks. Defaults to an in-memory adapter scoped to
108
+ * the current process, which loses state on restart and is not
109
+ * shared across processes. Pass a production adapter (Redis,
110
+ * Postgres, etc.) for deployed agents. The same instance is reused
111
+ * across the inbound webhook path and every event-handler context
112
+ * rebuild, keeping state coherent within a single agent run.
113
+ */
114
+ readonly stateAdapter?: SlackStateAdapter;
96
115
  /**
97
116
  * Override the default webhook route path (`/ash/v1/slack`).
98
117
  */
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createSlackAdapter } from "#compiled/@chat-adapter/slack/index.js";
2
3
  import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js";
3
4
  import { ThreadImpl, } from "#compiled/chat/index.js";
@@ -5,7 +6,7 @@ import { createLogger } from "#internal/logging.js";
5
6
  import { buildSlackTurnMessage, collectSlackFileParts, createSlackFetchFile, } from "#public/channels/slack/attachments.js";
6
7
  import { deriveHitlResponse, isHitlAction, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js";
7
8
  import { mergeUploadPolicy } from "#public/channels/upload-policy.js";
8
- import { defineChannel, POST } from "#public/definitions/defineChannel.js";
9
+ import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
9
10
  const log = createLogger("slack.channel");
10
11
  function decodeThreadId(id) {
11
12
  const parts = id.replace(/^slack:/u, "").split(":");
@@ -94,7 +95,7 @@ function resolveSlackAdapterCredentials(credentials) {
94
95
  const signingSecret = credentials?.signingSecret ?? (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET);
95
96
  return { botToken, signingSecret, webhookVerifier };
96
97
  }
97
- function rebuildSlackContext(state, credentials) {
98
+ function rebuildSlackContext(state, credentials, stateAdapter) {
98
99
  const { botToken, signingSecret, webhookVerifier } = resolveSlackAdapterCredentials(credentials);
99
100
  const adapter = createSlackAdapter({
100
101
  botToken,
@@ -109,20 +110,8 @@ function rebuildSlackContext(state, credentials) {
109
110
  id: "slack::",
110
111
  isDM: false,
111
112
  });
112
- // Pin both private adapter slots so the thread is fully self-
113
- // contained — no `Chat.getSingleton()` lookup needed when handlers
114
- // call `thread.post()`, `thread.startTyping()`, `thread.setState()`,
115
- // `thread.subscribe()`, etc. The `_adapter` line matches the runtime
116
- // behavior chat itself relies on for `ThreadImpl.fromJSON(json,
117
- // adapter)`. `ThreadImpl.fromJSON` does NOT pin a state adapter, so
118
- // without `_stateAdapterInstance` set, state-touching paths (cards,
119
- // callback URLs, `state`/`setState`, `subscribe()`/`isSubscribed()`)
120
- // would fall through to the singleton and throw — we use an in-
121
- // memory state adapter scoped to this rebuild so the asymmetry with
122
- // the fallback branch goes away and both branches converge to a
123
- // fully-pinned thread.
124
113
  Reflect.set(thread, "_adapter", adapter);
125
- Reflect.set(thread, "_stateAdapterInstance", createMemoryState());
114
+ Reflect.set(thread, "_stateAdapterInstance", stateAdapter);
126
115
  return {
127
116
  thread,
128
117
  slack: buildSlackApiHandle(thread, botToken, state.teamId ?? undefined),
@@ -152,6 +141,15 @@ export function slackChannel(config = {}) {
152
141
  const slackFetchFile = createSlackFetchFile({
153
142
  botToken: config.credentials?.botToken,
154
143
  });
144
+ const stateAdapter = config.stateAdapter ?? createMemoryState();
145
+ // Threads per-request `send`/`waitUntil` to the once-registered
146
+ // `onNewMention` listener. A shared mutable slot would race under
147
+ // concurrent webhook deliveries — `webhooks.slack` yields on
148
+ // `await req.text()`, so request B could overwrite A's slot before A's
149
+ // listener fires. ALS scopes the store to each route's async call tree.
150
+ // Allowlisted in `scripts/guard-agents-rules-baseline.json` (rule 19);
151
+ // this is channel-local request state, not Ash runtime state.
152
+ const requestContext = new AsyncLocalStorage();
155
153
  let chatPromise = null;
156
154
  async function getChat() {
157
155
  if (chatPromise)
@@ -161,9 +159,8 @@ export function slackChannel(config = {}) {
161
159
  if (!botToken) {
162
160
  throw new Error("slackChannel requires a bot token. Pass credentials.botToken or set SLACK_BOT_TOKEN.");
163
161
  }
164
- const [slackModule, stateModule, chatModule] = await Promise.all([
162
+ const [slackModule, chatModule] = await Promise.all([
165
163
  import("#compiled/@chat-adapter/slack/index.js"),
166
- import("#compiled/@chat-adapter/state-memory/index.js"),
167
164
  import("#compiled/chat/index.js"),
168
165
  ]);
169
166
  const slackAdapter = slackModule.createSlackAdapter({
@@ -174,10 +171,69 @@ export function slackChannel(config = {}) {
174
171
  });
175
172
  const chat = new chatModule.Chat({
176
173
  adapters: { slack: slackAdapter },
177
- state: stateModule.createMemoryState(),
174
+ state: stateAdapter,
178
175
  userName: config.botName ?? "ash-agent",
179
176
  });
180
177
  await chat.initialize();
178
+ // Register exactly once per chat instance — the chat SDK's
179
+ // `onNewMention` pushes handlers (does not replace), so registering
180
+ // on every webhook accumulates listeners on warm workers and the
181
+ // N-th `app_mention` dispatches N times.
182
+ chat.onNewMention(async (thread, message) => {
183
+ const rawEvent = message.raw;
184
+ // Slack sends both `app_mention` and `message.channels` for the same
185
+ // utterance. The Chat SDK dedup relies on in-memory state that doesn't
186
+ // survive serverless invocations, so both events reach this handler.
187
+ // Only process `app_mention` to prevent duplicate runs.
188
+ if (rawEvent?.type !== "app_mention")
189
+ return;
190
+ const ctx = requestContext.getStore();
191
+ if (!ctx) {
192
+ // Defensive: the chat SDK invokes handlers inside the
193
+ // `requestContext.run(...)` tree below, so this should never
194
+ // miss. Log and drop in case a future SDK ever defers
195
+ // invocation (e.g. via a queue or `setImmediate`).
196
+ log.warn("slack mention received but no request context is active");
197
+ return;
198
+ }
199
+ const teamId = rawEvent.team_id ?? rawEvent.team;
200
+ const slackCtx = {
201
+ thread,
202
+ slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
203
+ };
204
+ const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
205
+ if (runOpts === null)
206
+ return;
207
+ if (config.onMention) {
208
+ try {
209
+ await config.onMention(slackCtx, message);
210
+ }
211
+ catch (error) {
212
+ log.error("onMention handler failed", { error });
213
+ }
214
+ }
215
+ const decoded = decodeThreadId(thread.id ?? "");
216
+ const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
217
+ const fileParts = collectSlackFileParts(message, uploadPolicy);
218
+ const turnMessage = buildSlackTurnMessage(message.text, fileParts);
219
+ // Dispatch via `waitUntil` so the webhook ACKs within Slack's ~3s
220
+ // window even on cold starts. Awaiting `send` here would let a
221
+ // slow dispatch push the response past 3s and trigger Slack
222
+ // retries of the same `app_mention` (which the type filter
223
+ // cannot dedupe). Errors are logged; Slack already got its 200.
224
+ ctx.waitUntil(ctx
225
+ .send(turnMessage, {
226
+ auth: runOpts.auth,
227
+ continuationToken,
228
+ state: {
229
+ serializedThread: thread.toJSON(),
230
+ teamId: teamId ?? null,
231
+ },
232
+ })
233
+ .catch((error) => {
234
+ log.error("mention delivery failed", { error });
235
+ }));
236
+ });
181
237
  return { chat };
182
238
  })();
183
239
  chatPromise = promise;
@@ -191,7 +247,7 @@ export function slackChannel(config = {}) {
191
247
  state: { serializedThread: null, teamId: null },
192
248
  fetchFile: slackFetchFile,
193
249
  context(state) {
194
- return rebuildSlackContext(state, config.credentials);
250
+ return rebuildSlackContext(state, config.credentials, stateAdapter);
195
251
  },
196
252
  routes: [
197
253
  POST(config.route ?? "/ash/v1/slack", async (req, { send, waitUntil }) => {
@@ -255,44 +311,9 @@ export function slackChannel(config = {}) {
255
311
  }
256
312
  return new Response("ok", { status: 200 });
257
313
  }
258
- chat.onNewMention(async (thread, message) => {
259
- const rawEvent = message.raw;
260
- // Slack sends both `app_mention` and `message.channels` for the same
261
- // utterance. The Chat SDK dedup relies on in-memory state that doesn't
262
- // survive serverless invocations, so both events reach this handler.
263
- // Only process `app_mention` to prevent duplicate runs.
264
- if (rawEvent?.type !== "app_mention")
265
- return;
266
- const teamId = rawEvent.team_id ?? rawEvent.team;
267
- const slackCtx = {
268
- thread,
269
- slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
270
- };
271
- const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
272
- if (runOpts === null)
273
- return;
274
- if (config.onMention) {
275
- try {
276
- await config.onMention(slackCtx, message);
277
- }
278
- catch (error) {
279
- log.error("onMention handler failed", { error });
280
- }
281
- }
282
- const decoded = decodeThreadId(thread.id ?? "");
283
- const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
284
- const fileParts = collectSlackFileParts(message, uploadPolicy);
285
- const turnMessage = buildSlackTurnMessage(message.text, fileParts);
286
- await send(turnMessage, {
287
- auth: runOpts.auth,
288
- continuationToken,
289
- state: {
290
- serializedThread: thread.toJSON(),
291
- teamId: teamId ?? null,
292
- },
293
- });
294
- });
295
- return await chat.webhooks.slack(req, { waitUntil });
314
+ // Scope `send` / `waitUntil` to this request's async call tree so
315
+ // the `onNewMention` listener picks them up via `getStore()`.
316
+ return await requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, { waitUntil }));
296
317
  }),
297
318
  ],
298
319
  async receive(input, { send }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"