experimental-ash 0.15.0 → 0.16.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,21 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.16.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8cf749c: Expose the clicking actor as a `user` field on `SlackInteractionAction` so `onInteraction` handlers can attribute resolutions without re-parsing the raw payload.
8
+
9
+ ## 0.16.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4d7af3a: Remove `SlackThread.threadId` from the public Slack channel API and clarify Slack continuation-token encoding. Slack callers should use `ctx.slack.channelId` and `ctx.slack.threadTs` for real Slack API identifiers, while Ash keeps continuation tokens as channel-local values before runtime namespacing.
14
+
15
+ ### Patch Changes
16
+
17
+ - 8e4067a: Remove Ash-owned AI Gateway reporting tags from harness model calls. Authored `providerOptions.gateway.tags` and `mergeGatewayAutoCaching` behavior are unchanged.
18
+
3
19
  ## 0.15.0
4
20
 
5
21
  ### Minor Changes
@@ -88,7 +104,7 @@
88
104
 
89
105
  The resulting `ctx` exposes two handles so call sites read naturally:
90
106
 
91
- - `ctx.thread` (`SlackThread`) — `post`, `postEphemeral`, `startTyping`, `refresh`, `recentMessages`, `threadId`, `mentionUser`. Reads as "post a reply to this thread".
107
+ - `ctx.thread` (`SlackThread`) — `post`, `postEphemeral`, `startTyping`, `refresh`, `recentMessages`, `mentionUser`. Reads as "post a reply to this thread".
92
108
  - `ctx.slack` (`SlackHandle`) — `channelId`, `threadTs`, `teamId`, `request(op, body)`, `uploadFiles(...)`. Reads as "raw Slack API, possibly not the bound thread".
93
109
 
94
110
  `ctx.thread.post(...)` accepts ergonomic bare forms (`string` → `{ markdown }`, `CardElement` → `{ card }`) in addition to the explicit `SlackPostInput` variants:
@@ -33,11 +33,13 @@ Use `agent.ts` for:
33
33
 
34
34
  - model selection
35
35
  - metadata you want preserved in runtime traces
36
- - human-in-the-loop policy
37
36
  - hosted-build packaging controls
38
37
  - compaction settings
39
38
  - provider-specific model options
40
39
 
40
+ Per-tool approval (formerly "human-in-the-loop policy") lives on each tool via `needsApproval`,
41
+ not in `agent.ts`. See [Human In The Loop](./human-in-the-loop.md).
42
+
41
43
  For OpenTelemetry configuration, use `instrumentation.ts` instead. See
42
44
  [`instrumentation.ts`](./instrumentation.md).
43
45
 
@@ -130,29 +132,10 @@ server output instead of being bundled.
130
132
  The shared per-run workspace is configured on the sandbox, not on `agent.ts`. See
131
133
  [Workspace](./workspace.md) and [Sandboxes](./sandbox.md).
132
134
 
133
- ## `humanInTheLoop`
134
-
135
- `humanInTheLoop` controls whether Ash should pause for approval before executing model-visible
136
- tools.
137
-
138
- ```ts
139
- export default defineAgent({
140
- humanInTheLoop: {
141
- mode: "require-approval",
142
- },
143
- });
144
- ```
145
-
146
- Supported fields:
147
-
148
- - `mode: "require-approval" | "auto-allow"`
149
- - `allow?: readonly string[]`
150
- - `ask?: readonly string[]`
151
-
152
- Use `require-approval` when the default should be "ask first". Use `auto-allow` when the default
153
- should be "run immediately" and only a few named tools should still require approval.
135
+ ## Human In The Loop
154
136
 
155
- See [Human In The Loop](./human-in-the-loop.md) for the runtime flow, `input.requested`
137
+ Approval policy lives on individual tools via `needsApproval` in `defineTool({...})`, not on
138
+ `agent.ts`. See [Human In The Loop](./human-in-the-loop.md) for the runtime flow, `input.requested`
156
139
  events, and HTTP response payloads.
157
140
 
158
141
  ## Telemetry
@@ -66,7 +66,6 @@ Use the weather tool before answering forecast or temperature questions.
66
66
 
67
67
  ```md
68
68
  ---
69
- name: research
70
69
  description: Research unfamiliar topics before answering with confidence.
71
70
  ---
72
71
 
@@ -84,7 +83,8 @@ prompt.
84
83
  ### Skill Rules That Matter
85
84
 
86
85
  - packaged `SKILL.md` files must include YAML frontmatter
87
- - `name` and `description` are required in packaged skill frontmatter
86
+ - `description` is required in packaged skill frontmatter; identity is derived from the directory
87
+ name
88
88
  - flat markdown skills may omit frontmatter
89
89
  - tools stay visible whether or not a skill is activated
90
90
 
@@ -39,9 +39,7 @@ import { defineEvalSuite } from "experimental-ash/evals";
39
39
  import { Run } from "experimental-ash/evals/scores";
40
40
 
41
41
  export default defineEvalSuite({
42
- id: "weather-smoke",
43
42
  model: "openai/gpt-5.4-mini",
44
- name: "Weather Smoke",
45
43
  description: "Basic message and tool-usage coverage for the weather agent.",
46
44
  cases: [
47
45
  {
@@ -55,13 +53,12 @@ export default defineEvalSuite({
55
53
  ```
56
54
 
57
55
  `defineEvalSuite(...)` validates the suite shape and returns a normalized suite object that the CLI
58
- can discover and run.
56
+ can discover and run. Suite identity is derived from the `.eval.ts` file path — authored
57
+ definitions do not carry an `id` or `name`.
59
58
 
60
59
  Every suite must provide:
61
60
 
62
- - `id`
63
61
  - `model`
64
- - `name`
65
62
  - `scores`
66
63
  - either `cases` or `load`
67
64
 
@@ -88,9 +85,7 @@ You can provide cases directly:
88
85
 
89
86
  ```ts
90
87
  export default defineEvalSuite({
91
- id: "smoke",
92
88
  model: "openai/gpt-5.4-mini",
93
- name: "Smoke",
94
89
  cases: [
95
90
  {
96
91
  id: "hello",
@@ -110,9 +105,7 @@ import { loadYaml } from "experimental-ash/evals/loaders";
110
105
  import { Text } from "experimental-ash/evals/scores";
111
106
 
112
107
  export default defineEvalSuite({
113
- id: "marketing-sql",
114
108
  model: "openai/gpt-5.4-mini",
115
- name: "Marketing SQL",
116
109
  async load() {
117
110
  const doc = await loadYaml("evals/data/cases.yaml");
118
111
  return (doc.evals as readonly { task: string; prompt: string; sql: string }[]).map((row) => ({
@@ -131,9 +124,7 @@ message.
131
124
 
132
125
  ```ts
133
126
  defineEvalSuite({
134
- id: "weather-task",
135
127
  model: "openai/gpt-5.4-mini",
136
- name: "Weather Task",
137
128
  task: {
138
129
  prompt: (testCase) => `Answer the user: ${String(testCase.input)}`,
139
130
  parseOutput: (result) => result.finalMessage,
@@ -149,9 +140,7 @@ Suite thresholds let you relax a scorer from the default exact-match requirement
149
140
 
150
141
  ```ts
151
142
  defineEvalSuite({
152
- id: "weather",
153
143
  model: "openai/gpt-5.4-mini",
154
- name: "Weather",
155
144
  cases: [{ id: "hello", input: "Hello", expected: "Hello" }],
156
145
  scores: [Run.didNotFail(), Text.includes()],
157
146
  thresholds: {
@@ -180,8 +169,6 @@ import { defineEvalSuite } from "experimental-ash/evals";
180
169
  import { Autoevals, Run } from "experimental-ash/evals/scores";
181
170
 
182
171
  export default defineEvalSuite({
183
- id: "support-quality",
184
- name: "Support Quality",
185
172
  model: "openai/gpt-5.4-mini",
186
173
  cases: [{ id: "refund", input: "How do I get a refund?", expected: "refund policy" }],
187
174
  scores: [Run.didNotFail(), Autoevals.factuality()],
@@ -34,7 +34,7 @@ my-agent/
34
34
  | Path | Purpose | Notes |
35
35
  | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
36
  | `instructions.md` or `instructions.ts` | Base instructions prompt | Exactly one instructions prompt is expected. Module-backed sources execute once at build time; the resulting markdown is baked into the compiled manifest. The legacy `system.{md,ts,...}` slot is still discovered with a deprecation warning. |
37
- | `agent.ts` | Runtime config | Model, metadata, workspace, build, compaction |
37
+ | `agent.ts` | Runtime config | Model, metadata, build, compaction, modelOptions |
38
38
  | `instrumentation.ts` | Telemetry config | OTel exporter setup and AI SDK span settings; auto-discovered and run before agent code |
39
39
  | `channels/` | HTTP or messaging entrypoints | Root-only today |
40
40
  | `connections/` | External service connections (MCP) | Each file defines one connection; name derived from filename |
@@ -45,7 +45,7 @@ The smallest skill is just a markdown file.
45
45
  Use the weather tool before answering forecast or temperature questions.
46
46
  ```
47
47
 
48
- For flat skills, Ash derives the skill id and name from the file path.
48
+ For flat skills, Ash derives the skill id from the file path.
49
49
 
50
50
  You can also add frontmatter when you want to control the description explicitly:
51
51
 
@@ -63,7 +63,6 @@ Use a packaged skill when the procedure needs sibling files.
63
63
 
64
64
  ```md
65
65
  ---
66
- name: research
67
66
  description: Research unfamiliar topics before answering with confidence.
68
67
  ---
69
68
 
@@ -99,7 +98,8 @@ export default defineTool({
99
98
 
100
99
  - flat skills can be simple markdown files under `skills/*.md`
101
100
  - packaged skills live under `skills/<name>/SKILL.md`
102
- - packaged `SKILL.md` files must include `name` and `description` frontmatter
101
+ - packaged `SKILL.md` files must include `description` frontmatter; skill identity is derived from
102
+ the directory name
103
103
  - flat skills may omit frontmatter
104
104
 
105
105
  ## Write Good Descriptions
@@ -145,7 +145,6 @@ If markdown is not enough, you can author a skill in TypeScript with `defineSkil
145
145
  import { defineSkill } from "experimental-ash/skills";
146
146
 
147
147
  export default defineSkill({
148
- name: "research",
149
148
  description: "Research unfamiliar topics before answering with confidence.",
150
149
  markdown:
151
150
  "When the task is novel or ambiguous, gather evidence first, then answer with the key facts and the remaining uncertainty.",
@@ -106,7 +106,7 @@ Child `session.started.data.invocation` includes the parent call/session/turn li
106
106
 
107
107
  Local subagents may have their own:
108
108
 
109
- - `system`
109
+ - `instructions.md` or `instructions.ts`
110
110
  - `skills/`
111
111
  - `lib/`
112
112
  - `tools/`
@@ -263,7 +263,6 @@ unbounded text, cap it in the executor before returning:
263
263
 
264
264
  ```ts
265
265
  import { defineTool } from "experimental-ash/tools";
266
- import { truncateHead } from "experimental-ash/sandbox/truncate-output";
267
266
  import { z } from "zod";
268
267
 
269
268
  export default defineTool({
@@ -20,8 +20,9 @@ Source of truth:
20
20
  Use these to author the filesystem-backed surface. Each concern has its own subpath:
21
21
  `experimental-ash/tools` for tools, `experimental-ash/hooks` for hooks,
22
22
  `experimental-ash/sandbox` for the sandbox, `experimental-ash/skills` for skills,
23
- `experimental-ash/context` for context helpers, and the main `experimental-ash`
24
- barrel for agent config, schedules, and systems.
23
+ `experimental-ash/schedules` for schedules, `experimental-ash/instructions` for the instructions
24
+ prompt, `experimental-ash/context` for context helpers, and the main `experimental-ash` barrel
25
+ for `defineAgent`.
25
26
 
26
27
  - `defineAgent(...)` - additive runtime config for `agent.ts`. Subagents reuse the same helper at
27
28
  `subagents/<id>/agent.ts`; the only difference is that subagents must declare a `description` so
@@ -41,7 +42,7 @@ barrel for agent config, schedules, and systems.
41
42
 
42
43
  Framework defaults reachable for spread/wrap composition:
43
44
 
44
- - `bash`, `readFile`, `writeFile`, `webFetch`, `todo`, `loadSkill` (`experimental-ash/tools/defaults`)
45
+ - `bash`, `glob`, `grep`, `readFile`, `writeFile`, `webFetch`, `webSearch`, `todo`, `loadSkill` (`experimental-ash/tools/defaults`)
45
46
 
46
47
  Most apps use `defineAgent`, `defineTool`, and `defineSandbox` the most.
47
48
 
@@ -75,7 +76,7 @@ Channel and Slack types exported from `experimental-ash/channels/slack`:
75
76
  - `SlackHandle` - Slack identity handle with `channelId`, `threadTs`, `teamId`, `request()`,
76
77
  `uploadFiles()`
77
78
  - `SlackThread` - thread-scoped operations: `post`, `postEphemeral`, `startTyping`, `refresh`,
78
- `recentMessages`, `mentionUser`, `threadId`
79
+ `recentMessages`, `mentionUser`
79
80
  - `SlackMessage` - parsed inbound mention or DM payload (`text`, `markdown`, `author`,
80
81
  `attachments`, `threadTs`, `channelId`, ...)
81
82
  - `SlackInteractionAction` - action type for `onInteraction`
@@ -461,8 +461,8 @@ async function disposeHook(hook) {
461
461
  *
462
462
  * Used when no explicit continuation token was provided on RunInput
463
463
  * (schedule-initiated sessions). The token is derived from the
464
- * adapter's serialized thread identity — for Slack, this is the
465
- * thread id `slack:{channelId}:{threadTs}` when threadTs is populated.
464
+ * adapter's serialized resume identity — for Slack, this is the
465
+ * continuation token `slack:{channelId}:{threadTs}` when threadTs is populated.
466
466
  */
467
467
  function deriveAdapterContinuationToken(serializedContext) {
468
468
  const channel = serializedContext["ash.channel"];
@@ -47,12 +47,6 @@ export declare function getAnthropicCacheMarker(): AnthropicCacheMarker;
47
47
  * another value.
48
48
  */
49
49
  export declare function mergeGatewayAutoCaching(base: Readonly<Record<string, unknown>> | undefined): Record<string, unknown>;
50
- /**
51
- * Returns a new `providerOptions` object with `gateway.tags` merged into the
52
- * existing `gateway` sub-object. Preserves authored tags and de-dupes repeated
53
- * framework tags across retry and compaction paths.
54
- */
55
- export declare function mergeGatewayTags(base: Readonly<Record<string, unknown>> | undefined, tags: readonly string[]): Record<string, unknown>;
56
50
  /**
57
51
  * Returns a new ToolSet where the last tool entry carries the Anthropic
58
52
  * cache marker on `providerOptions`. Used on the `anthropic-direct` path
@@ -1,4 +1,3 @@
1
- import { createLogger } from "#internal/logging.js";
2
1
  /**
3
2
  * Shared frozen marker. All direct-Anthropic breakpoints in the harness share
4
3
  * this instance to avoid allocating per-message.
@@ -53,75 +52,6 @@ export function mergeGatewayAutoCaching(base) {
53
52
  gateway: mergedGateway,
54
53
  };
55
54
  }
56
- const MAX_GATEWAY_TAGS = 10;
57
- const MAX_GATEWAY_TAG_LENGTH = 64;
58
- const log = createLogger("harness.prompt-cache");
59
- function normalizeGatewayTag(tag) {
60
- const trimmed = tag.trim();
61
- if (!trimmed) {
62
- return undefined;
63
- }
64
- return trimmed
65
- .replace(/[^a-zA-Z0-9:_./-]+/g, "_")
66
- .replace(/_+/g, "_")
67
- .slice(0, MAX_GATEWAY_TAG_LENGTH);
68
- }
69
- function gatewayTagKey(tag) {
70
- const separatorIndex = tag.indexOf(":");
71
- return separatorIndex === -1 ? tag : tag.slice(0, separatorIndex);
72
- }
73
- /**
74
- * Returns a new `providerOptions` object with `gateway.tags` merged into the
75
- * existing `gateway` sub-object. Preserves authored tags and de-dupes repeated
76
- * framework tags across retry and compaction paths.
77
- */
78
- export function mergeGatewayTags(base, tags) {
79
- if (tags.length === 0) {
80
- return { ...base };
81
- }
82
- const baseGateway = base?.gateway !== undefined && typeof base.gateway === "object" && base.gateway !== null
83
- ? base.gateway
84
- : undefined;
85
- const authoredTags = Array.isArray(baseGateway?.tags)
86
- ? baseGateway.tags.filter((tag) => typeof tag === "string")
87
- : [];
88
- const mergedTags = [];
89
- for (const tag of authoredTags) {
90
- const normalized = normalizeGatewayTag(tag);
91
- if (!normalized || mergedTags.includes(normalized)) {
92
- continue;
93
- }
94
- mergedTags.push(normalized);
95
- if (mergedTags.length === MAX_GATEWAY_TAGS) {
96
- break;
97
- }
98
- }
99
- const droppedTags = [];
100
- for (const tag of tags) {
101
- const normalized = normalizeGatewayTag(tag);
102
- if (!normalized || mergedTags.includes(normalized)) {
103
- continue;
104
- }
105
- if (mergedTags.length === MAX_GATEWAY_TAGS) {
106
- droppedTags.push(normalized);
107
- continue;
108
- }
109
- mergedTags.push(normalized);
110
- }
111
- if (droppedTags.length > 0) {
112
- log.warn("dropped Ash AI Gateway reporting tags because Gateway tag limit was reached", {
113
- droppedTagKeys: [...new Set(droppedTags.map(gatewayTagKey))],
114
- limit: MAX_GATEWAY_TAGS,
115
- });
116
- }
117
- return {
118
- ...base,
119
- gateway: {
120
- ...baseGateway,
121
- tags: mergedTags,
122
- },
123
- };
124
- }
125
55
  /**
126
56
  * Returns a new ToolSet where the last tool entry carries the Anthropic
127
57
  * cache marker on `providerOptions`. Used on the `anthropic-direct` path
@@ -16,7 +16,6 @@ export interface StepHooksInput {
16
16
  readonly cachePath: PromptCachePath;
17
17
  readonly emit?: HarnessEmitFn;
18
18
  readonly emissionState: HarnessEmissionState;
19
- readonly gatewayTags?: readonly string[];
20
19
  readonly marker: AnthropicCacheMarker | undefined;
21
20
  readonly session: HarnessSession;
22
21
  }
@@ -2,7 +2,7 @@ import { createActionResultEvent, createActionsRequestedEvent, createStepComplet
2
2
  import { createRuntimeToolResultFromMessagePart, createRuntimeToolResultFromStepResult, } from "#harness/action-result-helpers.js";
3
3
  import { emitStepStarted, normalizeAssistantStepFinishReason } from "#harness/emission.js";
4
4
  import { extractToolApprovalInputRequests } from "#harness/input-extraction.js";
5
- import { applyConversationCacheControl, mergeGatewayAutoCaching, mergeGatewayTags, } from "#harness/prompt-cache.js";
5
+ import { applyConversationCacheControl, mergeGatewayAutoCaching, } from "#harness/prompt-cache.js";
6
6
  import { createRuntimeActionRequestFromToolCall } from "#harness/runtime-actions.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Builder
@@ -41,9 +41,7 @@ export function buildStepHooks(input) {
41
41
  messages: processed,
42
42
  };
43
43
  if (input.cachePath.kind === "gateway-auto") {
44
- let providerOptions = mergeGatewayAutoCaching(session.agent.modelReference.providerOptions);
45
- providerOptions = mergeGatewayTags(providerOptions, input.gatewayTags ?? []);
46
- stepResult.providerOptions = providerOptions;
44
+ stepResult.providerOptions = mergeGatewayAutoCaching(session.agent.modelReference.providerOptions);
47
45
  }
48
46
  return stepResult;
49
47
  };
@@ -18,7 +18,7 @@ import { getInstrumentationConfig } from "#harness/instrumentation-config.js";
18
18
  import { resolveAssistantStepText } from "#harness/messages.js";
19
19
  import { classifyModelCallError, summarizeKnownModelCallConfigError, } from "#harness/model-call-error.js";
20
20
  import { ensureOtelIntegration } from "#harness/otel-integration.js";
21
- import { applyLastToolCacheBreakpoint, detectPromptCachePath, getAnthropicCacheMarker, mergeGatewayTags, } from "#harness/prompt-cache.js";
21
+ import { applyLastToolCacheBreakpoint, detectPromptCachePath, getAnthropicCacheMarker, } from "#harness/prompt-cache.js";
22
22
  import { createRuntimeActionRequestFromToolCall, resolvePendingRuntimeActions, setPendingRuntimeActionBatch, } from "#harness/runtime-actions.js";
23
23
  import { buildStepHooks, emitStepActions, isInvalidToolCall, } from "#harness/step-hooks.js";
24
24
  import { pruneToolResults } from "#harness/tool-result-pruning.js";
@@ -105,20 +105,6 @@ function buildGatewayAttributionHeaders(model, runtimeIdentity) {
105
105
  headers["http-referer"] = referer;
106
106
  return headers;
107
107
  }
108
- function buildGatewayReportingTags({ runtimeIdentity, session, turnId, }) {
109
- const tags = ["framework:ash"];
110
- if (session.sessionId.startsWith("wrun_")) {
111
- tags.push(`workflowRunId:${session.sessionId}`);
112
- }
113
- tags.push(`sessionId:${session.sessionId}`);
114
- if (turnId) {
115
- tags.push(`turnId:${turnId}`);
116
- }
117
- if (runtimeIdentity?.agentId) {
118
- tags.push(`agent:${runtimeIdentity.agentId}`);
119
- }
120
- return tags;
121
- }
122
108
  // ---------------------------------------------------------------------------
123
109
  // Turn trace state — survives step boundaries via session.state
124
110
  // ---------------------------------------------------------------------------
@@ -262,15 +248,9 @@ export function createToolLoopHarness(config) {
262
248
  // Runs before `agent.stream()` so the compacted messages flow through
263
249
  // `messages` (which the harness uses to rebuild session history).
264
250
  const attributionHeaders = buildGatewayAttributionHeaders(model, config.runtimeIdentity);
265
- const gatewayTags = buildGatewayReportingTags({
266
- runtimeIdentity: config.runtimeIdentity,
267
- session,
268
- turnId: emissionState.turnId,
269
- });
270
251
  ({ messages, session } = await maybeCompact({
271
252
  emit,
272
253
  emissionState,
273
- gatewayTags,
274
254
  headers: attributionHeaders,
275
255
  messages,
276
256
  model,
@@ -304,7 +284,6 @@ export function createToolLoopHarness(config) {
304
284
  cachePath,
305
285
  emit,
306
286
  emissionState,
307
- gatewayTags,
308
287
  marker,
309
288
  session,
310
289
  });
@@ -627,13 +606,7 @@ async function maybeCompact(input) {
627
606
  usageInputTokens: getInputTokenCount(messages, session.compaction),
628
607
  }));
629
608
  }
630
- const compactionProviderOptions = typeof compaction.model === "string"
631
- ? mergeGatewayTags(compaction.providerOptions, [
632
- "operation:compaction",
633
- ...(input.gatewayTags ?? []),
634
- ])
635
- : compaction.providerOptions;
636
- messages = await compactMessages(messages, compaction.model, session.compaction, compactionProviderOptions, input.telemetry, input.headers);
609
+ messages = await compactMessages(messages, compaction.model, session.compaction, compaction.providerOptions, input.telemetry, input.headers);
637
610
  if (input.onCompaction) {
638
611
  const compacted = await input.onCompaction(session);
639
612
  session = compacted.session;
@@ -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.15.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.16.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",
@@ -22,22 +22,12 @@ import { type CardElement, type FileUpload } from "#compiled/chat/index.js";
22
22
  */
23
23
  export type SlackBotToken = string | (() => string | Promise<string>);
24
24
  /**
25
- * Decodes a Slack thread id of the form `slack:<channelId>:<threadTs>`
26
- * back into its parts. Returns empty-string fields when the input does
27
- * not match used by the channel's fallback paths where the channel
28
- * id has not yet been observed.
25
+ * Builds the Slack channel-local continuation token (`<channelId>:<threadTs>`).
26
+ * Route `send()` namespaces this with the channel name before handing it to the
27
+ * runtime (`slack:<channelId>:<threadTs>`), so Slack routes should pass the raw
28
+ * channel-local form here.
29
29
  */
30
- export declare function decodeThreadId(id: string): {
31
- channelId: string;
32
- threadTs: string;
33
- };
34
- /**
35
- * Builds the Ash thread id (`slack:<channelId>:<threadTs>`) used as the
36
- * channel's continuation token. Keeps the encoding in one place so the
37
- * inbound route, the interaction handler, and the context rebuild path
38
- * cannot drift on the format.
39
- */
40
- export declare function encodeThreadId(channelId: string, threadTs: string): string;
30
+ export declare function encodeSlackContinuationToken(channelId: string, threadTs: string): string;
41
31
  /**
42
32
  * Materializes a {@link SlackBotToken} to a concrete string. Falls
43
33
  * back to `process.env.SLACK_BOT_TOKEN` when no value was passed.
@@ -176,8 +166,6 @@ export interface SlackThreadMessage {
176
166
  * use {@link SlackHandle.request} on `ctx.slack` instead.
177
167
  */
178
168
  export interface SlackThread {
179
- /** Ash continuation-token thread id — `slack:<channelId>:<threadTs>`. */
180
- readonly threadId: string;
181
169
  /** Recently fetched thread messages. Populated by {@link refresh}. */
182
170
  readonly recentMessages: readonly SlackThreadMessage[];
183
171
  /**
@@ -20,23 +20,13 @@ import { encodeSlackApiBody } from "#public/channels/slack/api-encoding.js";
20
20
  import { cardToBlocks, cardToFallbackText } from "#public/channels/slack/blocks.js";
21
21
  const log = createLogger("slack.api");
22
22
  /**
23
- * Decodes a Slack thread id of the form `slack:<channelId>:<threadTs>`
24
- * back into its parts. Returns empty-string fields when the input does
25
- * not match used by the channel's fallback paths where the channel
26
- * id has not yet been observed.
23
+ * Builds the Slack channel-local continuation token (`<channelId>:<threadTs>`).
24
+ * Route `send()` namespaces this with the channel name before handing it to the
25
+ * runtime (`slack:<channelId>:<threadTs>`), so Slack routes should pass the raw
26
+ * channel-local form here.
27
27
  */
28
- export function decodeThreadId(id) {
29
- const parts = id.replace(/^slack:/u, "").split(":");
30
- return { channelId: parts[0] ?? "", threadTs: parts[1] ?? "" };
31
- }
32
- /**
33
- * Builds the Ash thread id (`slack:<channelId>:<threadTs>`) used as the
34
- * channel's continuation token. Keeps the encoding in one place so the
35
- * inbound route, the interaction handler, and the context rebuild path
36
- * cannot drift on the format.
37
- */
38
- export function encodeThreadId(channelId, threadTs) {
39
- return `slack:${channelId}:${threadTs}`;
28
+ export function encodeSlackContinuationToken(channelId, threadTs) {
29
+ return `${channelId}:${threadTs}`;
40
30
  }
41
31
  /**
42
32
  * Materializes a {@link SlackBotToken} to a concrete string. Falls
@@ -142,7 +132,6 @@ export function buildSlackBinding(input) {
142
132
  return { fileIds, raw: complete };
143
133
  }
144
134
  const thread = {
145
- threadId: encodeThreadId(input.channelId, input.threadTs),
146
135
  recentMessages: messages,
147
136
  async post(rawMessage) {
148
137
  const message = normalizePostInput(rawMessage);
@@ -27,11 +27,6 @@ interface ParsedBlockActionsPayload {
27
27
  readonly channelId: string;
28
28
  readonly threadTs: string;
29
29
  readonly teamId: string | undefined;
30
- /**
31
- * Slack actor that authored the click. Used to attribute the answered
32
- * card via `Answered by <@userId>` after a HITL response is delivered.
33
- */
34
- readonly userId: string | undefined;
35
30
  /**
36
31
  * The full block list off the clicked message. Preserved on the
37
32
  * answered-card update so the original prompt stays visible after the
@@ -17,7 +17,7 @@
17
17
  * work runs under `waitUntil` so the webhook ACK is immediate.
18
18
  */
19
19
  import { createLogger } from "#internal/logging.js";
20
- import { buildSlackBinding, encodeThreadId, resolveSlackBotToken, } from "#public/channels/slack/api.js";
20
+ import { buildSlackBinding, encodeSlackContinuationToken, resolveSlackBotToken, } from "#public/channels/slack/api.js";
21
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
22
  const log = createLogger("slack.interactions");
23
23
  /**
@@ -29,30 +29,37 @@ export function parseBlockActionsPayload(body) {
29
29
  const actions = body.actions;
30
30
  if (!Array.isArray(actions))
31
31
  return null;
32
+ // `channel` and `message` are Optional on block_actions payloads — only
33
+ // present when the action was triggered from a message in a channel.
32
34
  const channel = body.channel?.id;
33
35
  const message = body.message;
34
36
  const threadTs = message?.thread_ts ?? message?.ts;
35
37
  if (!channel || !threadTs)
36
38
  return null;
39
+ // `team` is Required but can be `null` for org-installed apps.
40
+ // `user` is Required and always carries `id`.
37
41
  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
- const messageTs = typeof message?.ts === "string" ? message.ts : undefined;
42
- const messageBlocks = Array.isArray(message?.blocks) ? message.blocks : [];
42
+ const userBlock = body.user;
43
+ const teamId = team?.id ?? userBlock.team_id;
44
+ const user = {
45
+ id: userBlock.id,
46
+ username: userBlock.username,
47
+ name: userBlock.name,
48
+ };
49
+ const messageBlocks = message?.blocks ?? [];
43
50
  return {
44
51
  actions: actions.map((a) => ({
45
52
  actionId: String(a.action_id ?? ""),
46
53
  value: a.value != null ? String(a.value) : undefined,
47
54
  blockId: a.block_id != null ? String(a.block_id) : undefined,
48
55
  selectedOptionValue: extractSelectedOptionValue(a),
49
- messageTs,
56
+ messageTs: message?.ts,
50
57
  label: extractActionLabel(a),
58
+ user,
51
59
  })),
52
60
  channelId: channel,
53
61
  threadTs,
54
62
  teamId,
55
- userId,
56
63
  messageBlocks,
57
64
  };
58
65
  }
@@ -117,7 +124,7 @@ export async function handleInteractionPost(rawBody, ctx, deps) {
117
124
  await openFreeformModal({ payload, interaction, freeformAction, deps });
118
125
  return ack;
119
126
  }
120
- const continuationToken = encodeThreadId(interaction.channelId, interaction.threadTs);
127
+ const continuationToken = encodeSlackContinuationToken(interaction.channelId, interaction.threadTs);
121
128
  const inputResponses = interaction.actions
122
129
  .map(deriveHitlResponse)
123
130
  .filter((r) => r !== null);
@@ -130,7 +137,7 @@ export async function handleInteractionPost(rawBody, ctx, deps) {
130
137
  channelId: interaction.channelId,
131
138
  threadTs: interaction.threadTs,
132
139
  teamId: interaction.teamId ?? null,
133
- triggeringUserId: interaction.userId ?? null,
140
+ triggeringUserId: interaction.actions[0]?.user.id ?? null,
134
141
  },
135
142
  })
136
143
  .catch((error) => {
@@ -177,7 +184,7 @@ async function openFreeformModal(input) {
177
184
  return;
178
185
  }
179
186
  const metadata = {
180
- continuationToken: encodeThreadId(input.interaction.channelId, input.interaction.threadTs),
187
+ continuationToken: encodeSlackContinuationToken(input.interaction.channelId, input.interaction.threadTs),
181
188
  channelId: input.interaction.channelId,
182
189
  threadTs: input.interaction.threadTs,
183
190
  messageTs,
@@ -222,9 +229,11 @@ async function handleViewSubmission(payload, ctx, _deps) {
222
229
  const text = typeof raw === "string" ? raw : "";
223
230
  if (text.length === 0)
224
231
  return ack;
232
+ // `user` is Required on view_submission payloads; `team_id` is on the
233
+ // user object in modern payloads but not guaranteed in all examples.
225
234
  const user = payload.user;
226
- const triggeringUserId = typeof user?.id === "string" ? user.id : null;
227
- const teamId = typeof user?.team_id === "string" ? user.team_id : null;
235
+ const triggeringUserId = user.id;
236
+ const teamId = user.team_id ?? null;
228
237
  ctx.waitUntil(ctx
229
238
  .send({ inputResponses: [{ requestId: metadata.requestId, text }] }, {
230
239
  auth: null,
@@ -260,7 +269,7 @@ async function updateAnsweredHitlCard(interaction, deps) {
260
269
  const blocks = buildAnsweredBlocks({
261
270
  promptBlock: findPromptBlock(interaction.messageBlocks),
262
271
  answerLabel,
263
- userId: interaction.userId,
272
+ userId: hitlAction.user.id,
264
273
  });
265
274
  const token = await resolveSlackBotToken(deps.config.credentials?.botToken);
266
275
  const response = await fetch("https://slack.com/api/chat.update", {
@@ -18,7 +18,7 @@ type EventData<T extends HandleMessageStreamEvent["type"]> = Extract<HandleMessa
18
18
  *
19
19
  * - {@link thread} owns thread-scoped operations (`post`,
20
20
  * `postEphemeral`, `startTyping`, `refresh`, `recentMessages`,
21
- * `threadId`, `mentionUser`). Reads as "post a reply to this thread".
21
+ * `mentionUser`). Reads as "post a reply to this thread".
22
22
  * - {@link slack} owns Slack identity (`channelId`, `threadTs`,
23
23
  * `teamId`) plus the raw-API escape hatch (`request`, `uploadFiles`).
24
24
  */
@@ -117,6 +117,24 @@ export interface SlackInteractionAction {
117
117
  * the "answered" card without re-fetching the original request.
118
118
  */
119
119
  readonly label?: string;
120
+ /**
121
+ * Slack actor who triggered the interaction. Lets `onInteraction`
122
+ * handlers attribute resolutions back to the clicker (e.g. "Filed by
123
+ * <@U0123456789>") without re-parsing the raw payload. Always present
124
+ * — Slack requires `user` on every `block_actions` payload.
125
+ */
126
+ readonly user: SlackInteractionUser;
127
+ }
128
+ /**
129
+ * Slack actor surfaced on {@link SlackInteractionAction.user}. Mirrors
130
+ * `body.user` from the inbound `block_actions` payload.
131
+ */
132
+ export interface SlackInteractionUser {
133
+ readonly id: string;
134
+ /** Modern canonical display handle. */
135
+ readonly username?: string;
136
+ /** Legacy display handle, kept for older workspaces. */
137
+ readonly name?: string;
120
138
  }
121
139
  /**
122
140
  * Result of an `onAppMention` or `onDirectMessage` callback. Return
@@ -1,5 +1,5 @@
1
1
  import { createLogger } from "#internal/logging.js";
2
- import { buildSlackBinding, encodeThreadId, } from "#public/channels/slack/api.js";
2
+ import { buildSlackBinding, encodeSlackContinuationToken, } from "#public/channels/slack/api.js";
3
3
  import { buildSlackTurnMessage, collectInboundFileParts, createSlackFetchFile, } from "#public/channels/slack/attachments.js";
4
4
  import { defaultEvents, defaultInputRequestedHandler, defaultOnAppMention, defaultOnDirectMessage, } from "#public/channels/slack/defaults.js";
5
5
  import { parseAppMentionEvent, parseDirectMessageEvent, prependSlackContext, } from "#public/channels/slack/inbound.js";
@@ -79,7 +79,7 @@ export function slackChannel(config = {}) {
79
79
  const threadTs = typeof input.args.threadTs === "string" ? input.args.threadTs : "";
80
80
  return send(input.message, {
81
81
  auth: input.auth,
82
- continuationToken: encodeThreadId(channelId, threadTs),
82
+ continuationToken: encodeSlackContinuationToken(channelId, threadTs),
83
83
  state: {
84
84
  channelId,
85
85
  threadTs: threadTs || null,
@@ -184,7 +184,7 @@ async function dispatchInboundMessage(input) {
184
184
  }
185
185
  if (result === null || result === undefined)
186
186
  return;
187
- const continuationToken = encodeThreadId(message.channelId, message.threadTs);
187
+ const continuationToken = encodeSlackContinuationToken(message.channelId, message.threadTs);
188
188
  const fileParts = await collectInboundFileParts({
189
189
  mention: message,
190
190
  thread,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"