experimental-ash 0.12.0 → 0.12.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,12 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 51d1669: Upgrade ai dependencies to latest canary
8
+ - f0b2cb5: Fix Slack channel regressions for assistant typing indicators, external file uploads, and native table rendering.
9
+
3
10
  ## 0.12.0
4
11
 
5
12
  ### 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.12.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.12.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",
@@ -0,0 +1,5 @@
1
+ export declare function encodeSlackApiBody(body: unknown, encoding: "form" | "json"): {
2
+ readonly body: string;
3
+ readonly contentType: string;
4
+ };
5
+ export declare function decodeSlackApiBody(body: unknown, contentType: string | null): unknown;
@@ -0,0 +1,46 @@
1
+ export function encodeSlackApiBody(body, encoding) {
2
+ if (encoding === "json") {
3
+ return {
4
+ body: JSON.stringify(body),
5
+ contentType: "application/json; charset=utf-8",
6
+ };
7
+ }
8
+ const params = new URLSearchParams();
9
+ if (body && typeof body === "object") {
10
+ for (const [key, value] of Object.entries(body)) {
11
+ if (value === undefined || value === null)
12
+ continue;
13
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
14
+ params.set(key, String(value));
15
+ }
16
+ else {
17
+ params.set(key, JSON.stringify(value));
18
+ }
19
+ }
20
+ }
21
+ return {
22
+ body: params.toString(),
23
+ contentType: "application/x-www-form-urlencoded",
24
+ };
25
+ }
26
+ export function decodeSlackApiBody(body, contentType) {
27
+ if (typeof body !== "string")
28
+ return body;
29
+ if (contentType?.includes("application/json"))
30
+ return parseJson(body);
31
+ if (!contentType?.includes("application/x-www-form-urlencoded"))
32
+ return body;
33
+ const parsed = {};
34
+ for (const [key, value] of new URLSearchParams(body)) {
35
+ parsed[key] = value.startsWith("[") || value.startsWith("{") ? parseJson(value) : value;
36
+ }
37
+ return parsed;
38
+ }
39
+ function parseJson(value) {
40
+ try {
41
+ return JSON.parse(value);
42
+ }
43
+ catch {
44
+ return value;
45
+ }
46
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { type CardElement, type FileUpload } from "#compiled/chat/index.js";
18
18
  /**
19
- * Slack bot token, materialized either as a literal `xoxb-…` string or
19
+ * Slack bot token, materialized either as a literal `xoxb-...` string or
20
20
  * as a (possibly async) function that returns one. The function form
21
21
  * supports secret-manager lookups and credential rotation.
22
22
  */
@@ -59,6 +59,7 @@ export declare function callSlackApi(input: {
59
59
  readonly botToken: SlackBotToken | undefined;
60
60
  readonly operation: string;
61
61
  readonly body: unknown;
62
+ readonly encoding?: "form" | "json";
62
63
  }): Promise<SlackApiResponse>;
63
64
  /**
64
65
  * Builds the `request(op, body)` Slack API caller installed on every
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { isCardElement } from "#compiled/chat/index.js";
18
18
  import { createLogger } from "#internal/logging.js";
19
+ import { encodeSlackApiBody } from "#public/channels/slack/api-encoding.js";
19
20
  import { cardToBlocks, cardToFallbackText } from "#public/channels/slack/blocks.js";
20
21
  const log = createLogger("slack.api");
21
22
  /**
@@ -56,13 +57,14 @@ export async function resolveSlackBotToken(token) {
56
57
  */
57
58
  export async function callSlackApi(input) {
58
59
  const token = await resolveSlackBotToken(input.botToken);
60
+ const encoded = encodeSlackApiBody(input.body, input.encoding ?? "json");
59
61
  const response = await fetch(`https://slack.com/api/${input.operation}`, {
60
62
  method: "POST",
61
63
  headers: {
62
64
  authorization: `Bearer ${token}`,
63
- "content-type": "application/json; charset=utf-8",
65
+ "content-type": encoded.contentType,
64
66
  },
65
- body: JSON.stringify(input.body),
67
+ body: encoded.body,
66
68
  });
67
69
  return response.json();
68
70
  }
@@ -92,9 +94,14 @@ export function buildSlackBinding(input) {
92
94
  const fileIds = [];
93
95
  for (const file of files) {
94
96
  const bytes = await readFileBytes(file.data);
95
- const getUrl = await request("files.getUploadURLExternal", {
96
- filename: file.filename,
97
- length: bytes.byteLength,
97
+ const getUrl = await callSlackApi({
98
+ botToken: input.botToken,
99
+ operation: "files.getUploadURLExternal",
100
+ encoding: "form",
101
+ body: {
102
+ filename: file.filename,
103
+ length: bytes.byteLength,
104
+ },
98
105
  });
99
106
  if (getUrl.ok !== true ||
100
107
  typeof getUrl.upload_url !== "string" ||
@@ -105,9 +112,9 @@ export function buildSlackBinding(input) {
105
112
  method: "POST",
106
113
  headers: {
107
114
  authorization: `Bearer ${token}`,
108
- "content-type": file.mimeType ?? "application/octet-stream",
115
+ "content-type": "application/octet-stream",
109
116
  },
110
- body: new Blob([bytes], { type: file.mimeType ?? "application/octet-stream" }),
117
+ body: bytes,
111
118
  });
112
119
  if (!uploadResponse.ok) {
113
120
  throw new Error(`Slack upload POST returned HTTP ${uploadResponse.status} for ${file.filename}.`);
@@ -123,7 +130,12 @@ export function buildSlackBinding(input) {
123
130
  completeBody.thread_ts = threadTs;
124
131
  if (options?.initialComment)
125
132
  completeBody.initial_comment = options.initialComment;
126
- const complete = await request("files.completeUploadExternal", completeBody);
133
+ const complete = await callSlackApi({
134
+ botToken: input.botToken,
135
+ operation: "files.completeUploadExternal",
136
+ encoding: "form",
137
+ body: completeBody,
138
+ });
127
139
  if (complete.ok !== true) {
128
140
  throw new Error(`Slack files.completeUploadExternal failed: ${complete.error ?? "unknown_error"}`);
129
141
  }
@@ -183,11 +195,15 @@ export function buildSlackBinding(input) {
183
195
  if (!input.channelId || !input.threadTs)
184
196
  return;
185
197
  try {
186
- const response = await request("assistant.threads.setStatus", {
198
+ const body = {
187
199
  channel_id: input.channelId,
188
200
  thread_ts: input.threadTs,
189
201
  status: status ?? "",
190
- });
202
+ };
203
+ if (status !== undefined && status.length > 0) {
204
+ body.loading_messages = [status];
205
+ }
206
+ const response = await request("assistant.threads.setStatus", body);
191
207
  if (response.ok !== true) {
192
208
  log.debug("assistant.threads.setStatus returned not-ok", {
193
209
  error: response.error,
@@ -361,18 +377,18 @@ function convertInline(input) {
361
377
  }
362
378
  /**
363
379
  * Normalize a {@link FileUpload.data} value (`Buffer | Blob | ArrayBuffer`) to
364
- * a contiguous `ArrayBuffer` we can both POST and length-prefix without
380
+ * a contiguous `Buffer` we can both POST and length-prefix without
365
381
  * holding two copies of the payload in memory.
366
382
  */
367
383
  async function readFileBytes(data) {
368
384
  if (data instanceof ArrayBuffer)
369
- return data;
385
+ return Buffer.from(data);
370
386
  if (typeof Blob !== "undefined" && data instanceof Blob) {
371
- return await data.arrayBuffer();
387
+ return Buffer.from(await data.arrayBuffer());
372
388
  }
373
389
  if (ArrayBuffer.isView(data)) {
374
390
  const view = data;
375
- return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
391
+ return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
376
392
  }
377
393
  throw new Error("FileUpload.data must be a Buffer, ArrayBuffer, or Blob.");
378
394
  }
@@ -34,6 +34,7 @@ import { truncatePlainText } from "#public/channels/slack/limits.js";
34
34
  */
35
35
  export function cardToBlocks(card) {
36
36
  const blocks = [];
37
+ const state = { usedNativeTable: false };
37
38
  if (card.title) {
38
39
  blocks.push({
39
40
  type: "header",
@@ -50,7 +51,7 @@ export function cardToBlocks(card) {
50
51
  blocks.push({ type: "image", image_url: card.imageUrl, alt_text: card.title ?? "" });
51
52
  }
52
53
  for (const child of card.children) {
53
- appendChildBlocks(child, blocks);
54
+ appendChildBlocks(child, blocks, state);
54
55
  }
55
56
  return blocks;
56
57
  }
@@ -72,7 +73,7 @@ export function cardToFallbackText(card) {
72
73
  }
73
74
  return lines.join("\n").trim();
74
75
  }
75
- function appendChildBlocks(child, blocks) {
76
+ function appendChildBlocks(child, blocks, state) {
76
77
  switch (child.type) {
77
78
  case "text":
78
79
  blocks.push(textToBlock(child));
@@ -88,7 +89,7 @@ function appendChildBlocks(child, blocks) {
88
89
  return;
89
90
  case "section":
90
91
  for (const inner of child.children) {
91
- appendChildBlocks(inner, blocks);
92
+ appendChildBlocks(inner, blocks, state);
92
93
  }
93
94
  return;
94
95
  case "fields":
@@ -98,7 +99,7 @@ function appendChildBlocks(child, blocks) {
98
99
  blocks.push(linkToBlock(child));
99
100
  return;
100
101
  case "table":
101
- blocks.push(tableToBlock(child));
102
+ blocks.push(...tableToBlocks(child, state));
102
103
  return;
103
104
  default: {
104
105
  const fallback = cardChildToFallbackText(child);
@@ -220,12 +221,34 @@ function linkToBlock(link) {
220
221
  };
221
222
  }
222
223
  /**
223
- * Renders a {@link TableElement} as a fixed-width mrkdwn block. Slack's
224
- * Block Kit has no native table this approximates the AsciiDoc
225
- * rendering the chat SDK fell back to before, with column widths sized
226
- * to the longest cell.
224
+ * Renders a {@link TableElement} as Slack's native table block when it
225
+ * fits Slack's limits. Slack allows one table block per message, so
226
+ * additional or oversized tables fall back to a fixed-width mrkdwn
227
+ * approximation.
227
228
  */
228
- function tableToBlock(table) {
229
+ function tableToBlocks(table, state) {
230
+ const MAX_NATIVE_TABLE_ROWS = 100;
231
+ const MAX_NATIVE_TABLE_COLUMNS = 20;
232
+ if (!state.usedNativeTable &&
233
+ table.rows.length <= MAX_NATIVE_TABLE_ROWS &&
234
+ table.headers.length <= MAX_NATIVE_TABLE_COLUMNS) {
235
+ state.usedNativeTable = true;
236
+ return [
237
+ {
238
+ type: "table",
239
+ rows: [
240
+ table.headers.map(tableCellToRawText),
241
+ ...table.rows.map((row) => row.map(tableCellToRawText)),
242
+ ],
243
+ },
244
+ ];
245
+ }
246
+ return [tableToFallbackBlock(table)];
247
+ }
248
+ function tableCellToRawText(value) {
249
+ return { type: "raw_text", text: value || " " };
250
+ }
251
+ function tableToFallbackBlock(table) {
229
252
  const widths = table.headers.map((header, columnIndex) => {
230
253
  let width = header.length;
231
254
  for (const row of table.rows) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"
@@ -160,7 +160,7 @@
160
160
  "@workflow/core": "5.0.0-beta.5",
161
161
  "@workflow/errors": "5.0.0-beta.2",
162
162
  "@workflow/world-local": "5.0.0-beta.4",
163
- "ai": "7.0.0-canary.136",
163
+ "ai": "7.0.0-canary.138",
164
164
  "autoevals": "0.0.132",
165
165
  "chat": "4.28.1",
166
166
  "chokidar": "5.0.0",
@@ -176,7 +176,7 @@
176
176
  },
177
177
  "peerDependencies": {
178
178
  "@opentelemetry/api": "^1.9.0",
179
- "ai": "7.0.0-canary.136",
179
+ "ai": "7.0.0-canary.138",
180
180
  "braintrust": ">=3.0.0"
181
181
  },
182
182
  "peerDependenciesMeta": {