experimental-ash 0.10.2 → 0.10.3

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/src/chunks/{dev-authored-source-watcher-CDT0dQQf.js → dev-authored-source-watcher-Dli96TWx.js} +1 -1
  3. package/dist/src/chunks/{host-M56X595D.js → host-Cp4XhAAX.js} +2 -2
  4. package/dist/src/chunks/{paths-B-Onq-sx.js → paths-CWTvKBbf.js} +1 -1
  5. package/dist/src/chunks/{prewarm-DQzlGOTi.js → prewarm-DIMA-oZ6.js} +1 -1
  6. package/dist/src/cli/commands/info.js +1 -1
  7. package/dist/src/cli/run.js +1 -1
  8. package/dist/src/compiled/.vendor-stamp.json +5 -5
  9. package/dist/src/compiled/@ai-sdk/anthropic/index.js +2 -2
  10. package/dist/src/compiled/@ai-sdk/anthropic/package.json +1 -1
  11. package/dist/src/compiled/@ai-sdk/google/index.js +8 -3
  12. package/dist/src/compiled/@ai-sdk/google/package.json +1 -1
  13. package/dist/src/compiled/@ai-sdk/mcp/index.js +1 -1
  14. package/dist/src/compiled/@ai-sdk/mcp/package.json +1 -1
  15. package/dist/src/compiled/@ai-sdk/openai/index.js +10 -6
  16. package/dist/src/compiled/@ai-sdk/openai/package.json +1 -1
  17. package/dist/src/compiled/@ai-sdk/otel/index.js +3 -3
  18. package/dist/src/compiled/@ai-sdk/otel/package.json +1 -1
  19. package/dist/src/compiled/@workflow/core/index.js +1 -1
  20. package/dist/src/compiled/@workflow/core/runtime.js +5 -5
  21. package/dist/src/compiled/@workflow/core/workflow.js +1 -1
  22. package/dist/src/compiled/@workflow/errors/index.js +1 -1
  23. package/dist/src/compiled/_chunks/workflow/{context-errors-zbKocOyk.js → context-errors-CmtmBosi.js} +1 -1
  24. package/dist/src/compiled/_chunks/workflow/dist-4zn5tehu.js +10 -0
  25. package/dist/src/compiled/_chunks/workflow/dist-DTWUhyDN.js +5 -0
  26. package/dist/src/compiled/_chunks/workflow/{resume-hook-CL8Ed91K.js → resume-hook-BqY8TqOE.js} +2 -2
  27. package/dist/src/compiled/_chunks/workflow/sleep-D30F1GSr.js +1 -0
  28. package/dist/src/evals/cli/eval.js +1 -1
  29. package/dist/src/internal/application/package.js +1 -1
  30. package/dist/src/public/channels/slack/api.d.ts +34 -0
  31. package/dist/src/public/channels/slack/api.js +55 -0
  32. package/dist/src/public/channels/slack/attachments.d.ts +17 -1
  33. package/dist/src/public/channels/slack/attachments.js +41 -0
  34. package/dist/src/public/channels/slack/connections.d.ts +57 -0
  35. package/dist/src/public/channels/slack/connections.js +70 -0
  36. package/dist/src/public/channels/slack/hitl.d.ts +74 -0
  37. package/dist/src/public/channels/slack/hitl.js +136 -9
  38. package/dist/src/public/channels/slack/inbound.d.ts +55 -0
  39. package/dist/src/public/channels/slack/inbound.js +75 -0
  40. package/dist/src/public/channels/slack/index.d.ts +1 -1
  41. package/dist/src/public/channels/slack/interactions.d.ts +74 -0
  42. package/dist/src/public/channels/slack/interactions.js +311 -0
  43. package/dist/src/public/channels/slack/limits.d.ts +43 -0
  44. package/dist/src/public/channels/slack/limits.js +52 -0
  45. package/dist/src/public/channels/slack/slack.d.ts +12 -2
  46. package/dist/src/public/channels/slack/slack.js +168 -12
  47. package/dist/src/public/channels/slack/slackChannel.d.ts +47 -1
  48. package/dist/src/public/channels/slack/slackChannel.js +41 -138
  49. package/dist/src/public/definitions/defineChannel.d.ts +2 -0
  50. package/dist/src/public/definitions/defineChannel.js +2 -0
  51. package/dist/src/shared/sandbox-session.d.ts +1 -1
  52. package/package.json +8 -8
  53. package/dist/src/compiled/_chunks/workflow/dist-Ci2brnHh.js +0 -14
  54. package/dist/src/compiled/_chunks/workflow/sleep-Dn3i9nxI.js +0 -1
  55. /package/dist/src/compiled/_chunks/workflow/{dist-0iNBqPYp.js → dist-B6aByiku.js} +0 -0
  56. /package/dist/src/compiled/_chunks/workflow/{dist-D774SUM4.js → dist-CVo7knbW.js} +0 -0
  57. /package/dist/src/compiled/_chunks/workflow/{src-ClRYdO4-.js → src-Bc9OYRaN.js} +0 -0
  58. /package/dist/src/compiled/_chunks/workflow/{symbols-D-4tVV8x.js → symbols-DkV1V0kM.js} +0 -0
  59. /package/dist/src/compiled/_chunks/workflow/{token-CsNmv7KW.js → token-Cq5QjRq8.js} +0 -0
  60. /package/dist/src/compiled/_chunks/workflow/{token-j5Cl4rrs.js → token-Duaoxfi5.js} +0 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Slack `block_actions` + `view_submission` wire handling.
3
+ *
4
+ * The route handler reads the form-encoded body, hands it here, and we:
5
+ *
6
+ * 1. Decode `block_actions` payloads into a typed shape the channel can
7
+ * work with — actions, channel/thread metadata, the clicker, and the
8
+ * full original block list for answered-card updates.
9
+ * 2. Open the freeform-answer modal inline when the click was a "Type
10
+ * your answer" button (Slack's `trigger_id` is only valid for ~3s,
11
+ * so this can't run under `waitUntil`).
12
+ * 3. Resolve `view_submission` payloads (freeform modal submissions)
13
+ * back into parked HITL requests via `send`.
14
+ *
15
+ * Anything we don't consume flows through to the user-supplied
16
+ * `onInteraction` callback. Always returns `Response("ok")` — followup
17
+ * work runs under `waitUntil` so the webhook ACK is immediate.
18
+ */
19
+ import { createLogger } from "#internal/logging.js";
20
+ import { buildSlackApiHandle, resolveSlackBotToken } from "#public/channels/slack/api.js";
21
+ import { buildAnsweredBlocks, buildFreeformModalView, deriveHitlResponse, freeformRequestIdFromActionId, HITL_FREEFORM_MODAL_ACTION_ID, HITL_FREEFORM_MODAL_BLOCK_ID, HITL_FREEFORM_MODAL_CALLBACK_ID, isFreeformAction, isHitlAction, } from "#public/channels/slack/hitl.js";
22
+ const log = createLogger("slack.interactions");
23
+ /**
24
+ * Decodes a Slack `block_actions` payload into a {@link ParsedBlockActionsPayload}.
25
+ * Returns `null` for payloads that don't carry the channel/thread
26
+ * metadata the handler needs.
27
+ */
28
+ export function parseBlockActionsPayload(body) {
29
+ const actions = body.actions;
30
+ if (!Array.isArray(actions))
31
+ return null;
32
+ const channel = body.channel?.id;
33
+ const message = body.message;
34
+ const threadTs = message?.thread_ts ?? message?.ts;
35
+ if (!channel || !threadTs)
36
+ return null;
37
+ const team = body.team;
38
+ const user = body.user;
39
+ const teamId = team?.id ?? user?.team_id;
40
+ const userId = typeof user?.id === "string" ? user.id : undefined;
41
+ // `message.ts` is the ts of the message that hosts the clicked component.
42
+ // It differs from `threadTs` whenever the bot's message is a thread reply,
43
+ // and is required for `chat.update` on the clicked message.
44
+ const messageTs = typeof message?.ts === "string" ? message.ts : undefined;
45
+ const messageBlocks = Array.isArray(message?.blocks) ? message.blocks : [];
46
+ return {
47
+ actions: actions.map((a) => ({
48
+ actionId: String(a.action_id ?? ""),
49
+ value: a.value != null ? String(a.value) : undefined,
50
+ blockId: a.block_id != null ? String(a.block_id) : undefined,
51
+ selectedOptionValue: extractSelectedOptionValue(a),
52
+ messageTs,
53
+ label: extractActionLabel(a),
54
+ })),
55
+ channelId: channel,
56
+ threadTs,
57
+ teamId,
58
+ userId,
59
+ messageBlocks,
60
+ };
61
+ }
62
+ function extractSelectedOptionValue(action) {
63
+ const selected = action.selected_option;
64
+ return typeof selected?.value === "string" ? selected.value : undefined;
65
+ }
66
+ function extractActionLabel(action) {
67
+ const selected = action.selected_option;
68
+ const fromSelected = selected?.text?.text;
69
+ if (typeof fromSelected === "string" && fromSelected.length > 0)
70
+ return fromSelected;
71
+ const buttonText = action.text?.text;
72
+ if (typeof buttonText === "string" && buttonText.length > 0)
73
+ return buttonText;
74
+ return undefined;
75
+ }
76
+ function findPromptBlock(blocks) {
77
+ for (const block of blocks) {
78
+ if (typeof block === "object" &&
79
+ block !== null &&
80
+ block.type === "section") {
81
+ return block;
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ function readPromptTextFromBlocks(blocks) {
87
+ const prompt = findPromptBlock(blocks);
88
+ const text = prompt?.text?.text;
89
+ return typeof text === "string" && text.length > 0 ? text : undefined;
90
+ }
91
+ /**
92
+ * Entry point for Slack's form-encoded interactivity endpoint. Routes
93
+ * `view_submission` payloads to the freeform-answer flow, intercepts
94
+ * "Type your answer" button clicks to open a modal, resolves
95
+ * framework HITL clicks against the parked session, and forwards
96
+ * anything else to `config.onInteraction`.
97
+ */
98
+ export async function handleInteractionPost(rawBody, ctx, deps) {
99
+ const ack = new Response("ok", { status: 200 });
100
+ const params = new URLSearchParams(rawBody);
101
+ const payloadStr = params.get("payload");
102
+ if (!payloadStr)
103
+ return ack;
104
+ let payload;
105
+ try {
106
+ payload = JSON.parse(payloadStr);
107
+ }
108
+ catch {
109
+ log.warn("failed to parse Slack interaction payload");
110
+ return ack;
111
+ }
112
+ if (payload?.type === "view_submission") {
113
+ return handleViewSubmission(payload, ctx, deps);
114
+ }
115
+ const interaction = parseBlockActionsPayload(payload);
116
+ if (!interaction)
117
+ return ack;
118
+ const freeformAction = interaction.actions.find((a) => isFreeformAction(a.actionId));
119
+ if (freeformAction) {
120
+ await openFreeformModal({ payload, interaction, freeformAction, deps });
121
+ return ack;
122
+ }
123
+ const continuationToken = `slack:${interaction.channelId}:${interaction.threadTs}`;
124
+ const inputResponses = interaction.actions
125
+ .map(deriveHitlResponse)
126
+ .filter((r) => r !== null);
127
+ if (inputResponses.length > 0) {
128
+ ctx.waitUntil(ctx
129
+ .send({ inputResponses }, {
130
+ auth: null,
131
+ continuationToken,
132
+ state: {
133
+ serializedThread: null,
134
+ teamId: interaction.teamId ?? null,
135
+ triggeringUserId: interaction.userId ?? null,
136
+ },
137
+ })
138
+ .catch((error) => {
139
+ log.error("HITL interaction delivery failed", { error });
140
+ }));
141
+ ctx.waitUntil(updateAnsweredHitlCard(interaction, deps).catch((error) => {
142
+ log.error("HITL answered-card update failed", { error });
143
+ }));
144
+ }
145
+ const onInteraction = deps.config.onInteraction;
146
+ if (onInteraction) {
147
+ const customActions = interaction.actions.filter((a) => !isHitlAction(a.actionId));
148
+ if (customActions.length > 0) {
149
+ const chatModule = await deps.loadChatModule();
150
+ const thread = new chatModule.ThreadImpl({
151
+ adapterName: "slack",
152
+ channelId: interaction.channelId,
153
+ id: `slack:${interaction.channelId}:${interaction.threadTs}`,
154
+ isDM: false,
155
+ });
156
+ const slackCtx = {
157
+ thread,
158
+ slack: buildSlackApiHandle(thread, deps.config.credentials?.botToken, interaction.teamId),
159
+ };
160
+ for (const action of customActions) {
161
+ ctx.waitUntil(Promise.resolve(onInteraction(action, slackCtx)).catch((error) => {
162
+ log.error("custom interaction handler failed", { error });
163
+ }));
164
+ }
165
+ }
166
+ }
167
+ return ack;
168
+ }
169
+ async function openFreeformModal(input) {
170
+ const triggerId = input.payload.trigger_id;
171
+ if (typeof triggerId !== "string" || triggerId.length === 0) {
172
+ log.warn("freeform button click missing trigger_id — cannot open modal");
173
+ return;
174
+ }
175
+ const requestId = freeformRequestIdFromActionId(input.freeformAction.actionId) ?? input.freeformAction.value;
176
+ if (!requestId) {
177
+ log.warn("freeform button click missing requestId");
178
+ return;
179
+ }
180
+ const messageTs = input.freeformAction.messageTs;
181
+ if (!messageTs) {
182
+ log.warn("freeform button click missing messageTs");
183
+ return;
184
+ }
185
+ const metadata = {
186
+ continuationToken: `slack:${input.interaction.channelId}:${input.interaction.threadTs}`,
187
+ channelId: input.interaction.channelId,
188
+ threadTs: input.interaction.threadTs,
189
+ messageTs,
190
+ requestId,
191
+ };
192
+ const promptText = readPromptTextFromBlocks(input.interaction.messageBlocks);
193
+ const view = buildFreeformModalView({ metadata, prompt: promptText });
194
+ const token = await resolveSlackBotToken(input.deps.config.credentials?.botToken);
195
+ const response = await fetch("https://slack.com/api/views.open", {
196
+ method: "POST",
197
+ headers: {
198
+ authorization: `Bearer ${token}`,
199
+ "content-type": "application/json; charset=utf-8",
200
+ },
201
+ body: JSON.stringify({ trigger_id: triggerId, view }),
202
+ });
203
+ if (!response.ok) {
204
+ log.error("Slack views.open returned non-2xx", { status: response.status });
205
+ }
206
+ }
207
+ async function handleViewSubmission(payload, ctx, deps) {
208
+ const ack = new Response("ok", { status: 200 });
209
+ const view = payload.view;
210
+ if (view?.callback_id !== HITL_FREEFORM_MODAL_CALLBACK_ID)
211
+ return ack;
212
+ let metadata;
213
+ try {
214
+ metadata = JSON.parse(view.private_metadata ?? "");
215
+ }
216
+ catch {
217
+ log.warn("freeform view_submission carries invalid private_metadata");
218
+ return ack;
219
+ }
220
+ if (!metadata.continuationToken ||
221
+ !metadata.requestId ||
222
+ !metadata.messageTs ||
223
+ !metadata.channelId ||
224
+ !metadata.threadTs) {
225
+ return ack;
226
+ }
227
+ const raw = view.state?.values?.[HITL_FREEFORM_MODAL_BLOCK_ID]?.[HITL_FREEFORM_MODAL_ACTION_ID]?.value;
228
+ const text = typeof raw === "string" ? raw : "";
229
+ if (text.length === 0)
230
+ return ack;
231
+ const user = payload.user;
232
+ const triggeringUserId = typeof user?.id === "string" ? user.id : null;
233
+ const teamId = typeof user?.team_id === "string" ? user.team_id : null;
234
+ ctx.waitUntil(ctx
235
+ .send({ inputResponses: [{ requestId: metadata.requestId, text }] }, {
236
+ auth: null,
237
+ continuationToken: metadata.continuationToken,
238
+ state: {
239
+ serializedThread: null,
240
+ teamId,
241
+ triggeringUserId,
242
+ },
243
+ })
244
+ .catch((error) => {
245
+ log.error("freeform answer delivery failed", { error });
246
+ }));
247
+ ctx.waitUntil(updateAnsweredFreeformCard({
248
+ channelId: metadata.channelId,
249
+ messageTs: metadata.messageTs,
250
+ answerLabel: text,
251
+ userId: triggeringUserId ?? undefined,
252
+ deps,
253
+ }).catch((error) => {
254
+ log.error("freeform answered-card update failed", { error });
255
+ }));
256
+ return ack;
257
+ }
258
+ async function updateAnsweredHitlCard(interaction, deps) {
259
+ const hitlAction = interaction.actions.find((a) => isHitlAction(a.actionId));
260
+ if (!hitlAction || !hitlAction.messageTs)
261
+ return;
262
+ const answerLabel = hitlAction.label ?? hitlAction.selectedOptionValue ?? hitlAction.value;
263
+ if (!answerLabel)
264
+ return;
265
+ const blocks = buildAnsweredBlocks({
266
+ promptBlock: findPromptBlock(interaction.messageBlocks),
267
+ answerLabel,
268
+ userId: interaction.userId,
269
+ });
270
+ const token = await resolveSlackBotToken(deps.config.credentials?.botToken);
271
+ const response = await fetch("https://slack.com/api/chat.update", {
272
+ method: "POST",
273
+ headers: {
274
+ authorization: `Bearer ${token}`,
275
+ "content-type": "application/json; charset=utf-8",
276
+ },
277
+ body: JSON.stringify({
278
+ channel: interaction.channelId,
279
+ ts: hitlAction.messageTs,
280
+ blocks,
281
+ text: `Answered: ${answerLabel}`,
282
+ }),
283
+ });
284
+ if (!response.ok) {
285
+ throw new Error(`Slack chat.update returned HTTP ${response.status}`);
286
+ }
287
+ }
288
+ async function updateAnsweredFreeformCard(input) {
289
+ const blocks = buildAnsweredBlocks({
290
+ promptBlock: undefined,
291
+ answerLabel: input.answerLabel,
292
+ userId: input.userId,
293
+ });
294
+ const token = await resolveSlackBotToken(input.deps.config.credentials?.botToken);
295
+ const response = await fetch("https://slack.com/api/chat.update", {
296
+ method: "POST",
297
+ headers: {
298
+ authorization: `Bearer ${token}`,
299
+ "content-type": "application/json; charset=utf-8",
300
+ },
301
+ body: JSON.stringify({
302
+ channel: input.channelId,
303
+ ts: input.messageTs,
304
+ blocks,
305
+ text: `Answered: ${input.answerLabel}`,
306
+ }),
307
+ });
308
+ if (!response.ok) {
309
+ throw new Error(`Slack chat.update returned HTTP ${response.status}`);
310
+ }
311
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Slack API string-length guards.
3
+ *
4
+ * Slack rejects payloads where a string exceeds its surface-specific
5
+ * limit (typing indicator, Block Kit `plain_text` fields, modal titles,
6
+ * etc.). The chat SDK does not enforce these for us — anything that
7
+ * overruns surfaces as a `chat.postMessage` / `assistant.threads.setStatus`
8
+ * / `views.open` HTTP error. These helpers cap strings before they cross
9
+ * the wire so a single long tool name or option label cannot fail the
10
+ * whole event handler.
11
+ */
12
+ /**
13
+ * Typing indicator (`assistant.threads.setStatus`) caps at roughly 100
14
+ * characters; we use 50 to match the pre-existing UX (statuses longer
15
+ * than a glance are hard to read in the chat UI anyway).
16
+ */
17
+ export declare const SLACK_TYPING_STATUS_MAX_LENGTH = 50;
18
+ /**
19
+ * Block Kit `plain_text` fields used in `static_select` / `radio_buttons`
20
+ * options and button labels are capped at 75 characters by Slack.
21
+ */
22
+ export declare const SLACK_BLOCK_KIT_PLAIN_TEXT_MAX_LENGTH = 75;
23
+ /**
24
+ * `views.open` modal title is capped at 24 characters.
25
+ */
26
+ export declare const SLACK_MODAL_TITLE_MAX_LENGTH = 24;
27
+ /**
28
+ * Normalizes a typing status: trims, collapses runs of whitespace into a
29
+ * single space, then truncates to {@link SLACK_TYPING_STATUS_MAX_LENGTH}
30
+ * with a trailing ellipsis when needed.
31
+ */
32
+ export declare function truncateTypingStatus(status: string): string;
33
+ /**
34
+ * Caps a Block Kit `plain_text` label/description at the Slack limit
35
+ * with a trailing ellipsis. Pass `undefined` to short-circuit (option
36
+ * descriptions are optional).
37
+ */
38
+ export declare function truncatePlainText(value: string): string;
39
+ export declare function truncatePlainText(value: string | undefined): string | undefined;
40
+ /**
41
+ * Caps a modal title at the Slack limit with a trailing ellipsis.
42
+ */
43
+ export declare function truncateModalTitle(value: string): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Slack API string-length guards.
3
+ *
4
+ * Slack rejects payloads where a string exceeds its surface-specific
5
+ * limit (typing indicator, Block Kit `plain_text` fields, modal titles,
6
+ * etc.). The chat SDK does not enforce these for us — anything that
7
+ * overruns surfaces as a `chat.postMessage` / `assistant.threads.setStatus`
8
+ * / `views.open` HTTP error. These helpers cap strings before they cross
9
+ * the wire so a single long tool name or option label cannot fail the
10
+ * whole event handler.
11
+ */
12
+ /**
13
+ * Typing indicator (`assistant.threads.setStatus`) caps at roughly 100
14
+ * characters; we use 50 to match the pre-existing UX (statuses longer
15
+ * than a glance are hard to read in the chat UI anyway).
16
+ */
17
+ export const SLACK_TYPING_STATUS_MAX_LENGTH = 50;
18
+ /**
19
+ * Block Kit `plain_text` fields used in `static_select` / `radio_buttons`
20
+ * options and button labels are capped at 75 characters by Slack.
21
+ */
22
+ export const SLACK_BLOCK_KIT_PLAIN_TEXT_MAX_LENGTH = 75;
23
+ /**
24
+ * `views.open` modal title is capped at 24 characters.
25
+ */
26
+ export const SLACK_MODAL_TITLE_MAX_LENGTH = 24;
27
+ /**
28
+ * Normalizes a typing status: trims, collapses runs of whitespace into a
29
+ * single space, then truncates to {@link SLACK_TYPING_STATUS_MAX_LENGTH}
30
+ * with a trailing ellipsis when needed.
31
+ */
32
+ export function truncateTypingStatus(status) {
33
+ const normalized = status.trim().replace(/\s+/gu, " ");
34
+ return truncateWithEllipsis(normalized, SLACK_TYPING_STATUS_MAX_LENGTH);
35
+ }
36
+ export function truncatePlainText(value) {
37
+ if (value === undefined)
38
+ return undefined;
39
+ return truncateWithEllipsis(value, SLACK_BLOCK_KIT_PLAIN_TEXT_MAX_LENGTH);
40
+ }
41
+ /**
42
+ * Caps a modal title at the Slack limit with a trailing ellipsis.
43
+ */
44
+ export function truncateModalTitle(value) {
45
+ return truncateWithEllipsis(value, SLACK_MODAL_TITLE_MAX_LENGTH);
46
+ }
47
+ function truncateWithEllipsis(value, maxLength) {
48
+ if (value.length <= maxLength)
49
+ return value;
50
+ const sliceLength = Math.max(0, maxLength - 3);
51
+ return `${value.slice(0, sliceLength).trimEnd()}...`;
52
+ }
@@ -1,10 +1,20 @@
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, type SlackStateAdapter } from "#public/channels/slack/slackChannel.js";
3
+ import { type SlackChannelCredentials, type SlackChannelState, type SlackContext, type SlackEventContext, type SlackStateAdapter } from "#public/channels/slack/slackChannel.js";
4
4
  export interface SlackOptions {
5
- readonly auth?: (message: import("#compiled/chat/index.js").Message) => SessionAuthContext | null;
5
+ /**
6
+ * Maps a verified inbound Slack mention to a {@link SessionAuthContext}.
7
+ * Receives the chat SDK `Message` plus a {@link SlackContext} carrying
8
+ * the live `thread` and `slack` API handle so policies can read
9
+ * channel/thread metadata without re-parsing `message.raw`.
10
+ *
11
+ * Defaults to a workspace-scoped projection of the Slack actor; return
12
+ * `null` to silently drop the mention.
13
+ */
14
+ readonly auth?: (message: import("#compiled/chat/index.js").Message, ctx: SlackContext) => SessionAuthContext | null;
6
15
  readonly credentials?: SlackChannelCredentials;
7
16
  readonly botName?: string;
8
17
  readonly stateAdapter?: SlackStateAdapter;
9
18
  }
10
19
  export declare function slack(options?: SlackOptions): Channel<SlackChannelState>;
20
+ export type { SlackEventContext };
@@ -1,43 +1,199 @@
1
+ import { createLogger, extractErrorId, formatErrorHint } from "#internal/logging.js";
2
+ import { buildAuthCompletedText, buildAuthEphemeralBlocks, buildAuthRequiredPublicText, formatConnectionDisplayName, } from "#public/channels/slack/connections.js";
3
+ import { truncateTypingStatus } from "#public/channels/slack/limits.js";
1
4
  import { slackChannel, } from "#public/channels/slack/slackChannel.js";
2
- function defaultSlackAuth(message) {
5
+ const log = createLogger("slack.defaults");
6
+ function readTeamId(message) {
7
+ const raw = message.raw;
8
+ const teamId = raw?.team_id ?? raw?.team;
9
+ return typeof teamId === "string" && teamId.length > 0 ? teamId : undefined;
10
+ }
11
+ function defaultSlackAuth(message, ctx) {
3
12
  const author = message.author;
4
13
  if (!author)
5
14
  return null;
15
+ const teamId = readTeamId(message);
16
+ const isBot = author.isBot === true;
17
+ const userId = String(author.userId);
18
+ const principalId = teamId
19
+ ? isBot
20
+ ? `slack:${teamId}:bot:${userId}`
21
+ : `slack:${teamId}:${userId}`
22
+ : isBot
23
+ ? `slack:bot:${userId}`
24
+ : `slack:${userId}`;
25
+ const attributes = {
26
+ author_type: isBot ? "bot" : "user",
27
+ channel_id: ctx.slack.channelId,
28
+ thread_ts: ctx.slack.threadTs,
29
+ user_id: userId,
30
+ };
31
+ if (typeof author.userName === "string" && author.userName.length > 0) {
32
+ attributes.user_name = author.userName;
33
+ }
34
+ if (typeof author.fullName === "string" && author.fullName.length > 0) {
35
+ attributes.full_name = author.fullName;
36
+ }
37
+ if (teamId !== undefined) {
38
+ attributes.team_id = teamId;
39
+ }
6
40
  return {
7
- attributes: {},
8
- authenticator: "slack",
9
- principalId: author.userId,
10
- principalType: author.isBot ? "service" : "user",
41
+ attributes,
42
+ authenticator: "slack-webhook",
43
+ issuer: teamId !== undefined ? `slack:${teamId}` : "slack",
44
+ principalId,
45
+ principalType: isBot ? "service" : "user",
11
46
  };
12
47
  }
48
+ /**
49
+ * Reads the first non-empty line of a model-emitted message. Used to
50
+ * surface the model's own pre-tool-call narration as the typing
51
+ * indicator on the next `actions.requested` event.
52
+ */
53
+ function firstNonEmptyLine(text) {
54
+ for (const line of text.split(/\r?\n/u)) {
55
+ const trimmed = line.trim();
56
+ if (trimmed.length > 0)
57
+ return trimmed;
58
+ }
59
+ return undefined;
60
+ }
13
61
  export function slack(options = {}) {
14
62
  const resolveAuth = options.auth ?? defaultSlackAuth;
15
63
  return slackChannel({
16
64
  credentials: options.credentials,
17
65
  botName: options.botName,
18
66
  stateAdapter: options.stateAdapter,
19
- run(_ctx, message) {
20
- return { auth: resolveAuth(message) };
67
+ run(ctx, message) {
68
+ return { auth: resolveAuth(message, ctx) };
21
69
  },
22
70
  async onMention(ctx) {
23
71
  await ctx.thread.startTyping("Thinking...");
24
72
  },
25
73
  events: {
26
74
  async "turn.started"(_event, ctx) {
75
+ ctx.state.pendingToolCallMessage = null;
27
76
  await ctx.thread.startTyping("Working...");
28
77
  },
29
78
  async "actions.requested"(event, ctx) {
79
+ const buffered = ctx.state.pendingToolCallMessage;
80
+ ctx.state.pendingToolCallMessage = null;
81
+ if (buffered) {
82
+ await ctx.thread.startTyping(truncateTypingStatus(buffered));
83
+ return;
84
+ }
30
85
  const labels = event.actions.map((a) => (a.kind === "tool-call" ? a.toolName : a.kind));
31
- await ctx.thread.startTyping(`Running ${labels.join(", ")}...`);
86
+ await ctx.thread.startTyping(truncateTypingStatus(`Running ${labels.join(", ")}...`));
32
87
  },
33
88
  async "message.completed"(event, ctx) {
34
- if (event.finishReason === "tool-calls")
89
+ if (event.finishReason === "tool-calls") {
90
+ // Buffer the model's prose so `actions.requested` can surface
91
+ // it as the typing indicator. Don't post — there's no
92
+ // user-visible answer yet, just narration before a tool call.
93
+ ctx.state.pendingToolCallMessage = event.message
94
+ ? (firstNonEmptyLine(event.message) ?? null)
95
+ : null;
35
96
  return;
97
+ }
98
+ ctx.state.pendingToolCallMessage = null;
36
99
  if (event.message)
37
- await ctx.thread.post(event.message);
100
+ await ctx.thread.post({ markdown: event.message });
38
101
  },
39
- async "session.failed"(_event, ctx) {
40
- await ctx.thread.post("Something went wrong.");
102
+ async "turn.failed"(event, ctx) {
103
+ const hint = formatErrorHint(event);
104
+ const errorId = extractErrorId(event.details);
105
+ await ctx.thread.post({
106
+ markdown: [
107
+ `I hit an error while handling your request${hint}.`,
108
+ "",
109
+ "Please try again, rephrase, or reach out if it keeps failing.",
110
+ ...(errorId ? ["", `_Error id: \`${errorId}\`_`] : []),
111
+ ].join("\n"),
112
+ });
113
+ },
114
+ async "session.failed"(event, ctx) {
115
+ const hint = formatErrorHint(event);
116
+ const errorId = extractErrorId(event.details);
117
+ await ctx.thread.post({
118
+ markdown: [
119
+ `This session couldn't recover from an error${hint}.`,
120
+ "",
121
+ "Start a new thread to continue — I can't pick this one back up.",
122
+ ...(errorId ? ["", `_Error id: \`${errorId}\`_`] : []),
123
+ ].join("\n"),
124
+ });
125
+ },
126
+ async "connection.authorization_required"(event, ctx) {
127
+ const displayName = formatConnectionDisplayName(event.connectionName);
128
+ const triggeringUserId = ctx.state.triggeringUserId ?? null;
129
+ const challengeUrl = event.authorization?.url;
130
+ if (triggeringUserId && challengeUrl) {
131
+ try {
132
+ await ctx.slack.request("chat.postEphemeral", {
133
+ channel: ctx.slack.channelId,
134
+ user: triggeringUserId,
135
+ thread_ts: ctx.slack.threadTs,
136
+ blocks: buildAuthEphemeralBlocks({ displayName, url: challengeUrl }),
137
+ text: `Sign in with ${displayName}: ${challengeUrl}`,
138
+ });
139
+ }
140
+ catch (error) {
141
+ log.error("Slack auth ephemeral delivery failed", {
142
+ connectionName: event.connectionName,
143
+ error,
144
+ });
145
+ }
146
+ }
147
+ const publicText = buildAuthRequiredPublicText({
148
+ displayName,
149
+ hasUser: triggeringUserId !== null,
150
+ });
151
+ try {
152
+ const sent = await ctx.thread.post({ markdown: publicText });
153
+ const sentId = sent && typeof sent === "object" && "id" in sent
154
+ ? sent.id
155
+ : undefined;
156
+ if (typeof sentId === "string") {
157
+ ctx.state.pendingAuthMessageTs = {
158
+ ...ctx.state.pendingAuthMessageTs,
159
+ [event.connectionName]: sentId,
160
+ };
161
+ }
162
+ }
163
+ catch (error) {
164
+ log.error("Slack auth public message delivery failed", {
165
+ connectionName: event.connectionName,
166
+ error,
167
+ });
168
+ }
169
+ },
170
+ async "connection.authorization_completed"(event, ctx) {
171
+ const pending = ctx.state.pendingAuthMessageTs ?? {};
172
+ const ts = pending[event.connectionName];
173
+ if (ts === undefined)
174
+ return;
175
+ const displayName = formatConnectionDisplayName(event.connectionName);
176
+ const text = buildAuthCompletedText({
177
+ displayName,
178
+ outcome: event.outcome,
179
+ reason: event.reason,
180
+ });
181
+ try {
182
+ await ctx.slack.request("chat.update", {
183
+ channel: ctx.slack.channelId,
184
+ ts,
185
+ text,
186
+ });
187
+ }
188
+ catch (error) {
189
+ log.error("Slack auth status edit failed", {
190
+ connectionName: event.connectionName,
191
+ error,
192
+ });
193
+ }
194
+ const next = { ...pending };
195
+ delete next[event.connectionName];
196
+ ctx.state.pendingAuthMessageTs = next;
41
197
  },
42
198
  },
43
199
  });