experimental-ash 0.9.0 → 0.10.0

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,17 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5bc39de: feat(slack): pluggable `stateAdapter` for production persistence
8
+
9
+ `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.
10
+
11
+ 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.
12
+
13
+ A new exported type alias `SlackStateAdapter` aliases the chat SDK's `StateAdapter` interface.
14
+
3
15
  ## 0.9.0
4
16
 
5
17
  ### 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.0";
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
  */
@@ -94,7 +94,7 @@ function resolveSlackAdapterCredentials(credentials) {
94
94
  const signingSecret = credentials?.signingSecret ?? (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET);
95
95
  return { botToken, signingSecret, webhookVerifier };
96
96
  }
97
- function rebuildSlackContext(state, credentials) {
97
+ function rebuildSlackContext(state, credentials, stateAdapter) {
98
98
  const { botToken, signingSecret, webhookVerifier } = resolveSlackAdapterCredentials(credentials);
99
99
  const adapter = createSlackAdapter({
100
100
  botToken,
@@ -109,20 +109,8 @@ function rebuildSlackContext(state, credentials) {
109
109
  id: "slack::",
110
110
  isDM: false,
111
111
  });
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
112
  Reflect.set(thread, "_adapter", adapter);
125
- Reflect.set(thread, "_stateAdapterInstance", createMemoryState());
113
+ Reflect.set(thread, "_stateAdapterInstance", stateAdapter);
126
114
  return {
127
115
  thread,
128
116
  slack: buildSlackApiHandle(thread, botToken, state.teamId ?? undefined),
@@ -152,6 +140,7 @@ export function slackChannel(config = {}) {
152
140
  const slackFetchFile = createSlackFetchFile({
153
141
  botToken: config.credentials?.botToken,
154
142
  });
143
+ const stateAdapter = config.stateAdapter ?? createMemoryState();
155
144
  let chatPromise = null;
156
145
  async function getChat() {
157
146
  if (chatPromise)
@@ -161,9 +150,8 @@ export function slackChannel(config = {}) {
161
150
  if (!botToken) {
162
151
  throw new Error("slackChannel requires a bot token. Pass credentials.botToken or set SLACK_BOT_TOKEN.");
163
152
  }
164
- const [slackModule, stateModule, chatModule] = await Promise.all([
153
+ const [slackModule, chatModule] = await Promise.all([
165
154
  import("#compiled/@chat-adapter/slack/index.js"),
166
- import("#compiled/@chat-adapter/state-memory/index.js"),
167
155
  import("#compiled/chat/index.js"),
168
156
  ]);
169
157
  const slackAdapter = slackModule.createSlackAdapter({
@@ -174,7 +162,7 @@ export function slackChannel(config = {}) {
174
162
  });
175
163
  const chat = new chatModule.Chat({
176
164
  adapters: { slack: slackAdapter },
177
- state: stateModule.createMemoryState(),
165
+ state: stateAdapter,
178
166
  userName: config.botName ?? "ash-agent",
179
167
  });
180
168
  await chat.initialize();
@@ -191,7 +179,7 @@ export function slackChannel(config = {}) {
191
179
  state: { serializedThread: null, teamId: null },
192
180
  fetchFile: slackFetchFile,
193
181
  context(state) {
194
- return rebuildSlackContext(state, config.credentials);
182
+ return rebuildSlackContext(state, config.credentials, stateAdapter);
195
183
  },
196
184
  routes: [
197
185
  POST(config.route ?? "/ash/v1/slack", async (req, { send, waitUntil }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"