experimental-ash 0.18.1 → 0.18.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 (59) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/dist/docs/public/channels/README.md +75 -9
  3. package/dist/docs/public/schedules.md +13 -4
  4. package/dist/src/channel/adapter-context.d.ts +4 -0
  5. package/dist/src/channel/adapter-context.js +6 -0
  6. package/dist/src/channel/adapter.d.ts +10 -10
  7. package/dist/src/channel/cross-channel-receive.d.ts +1 -1
  8. package/dist/src/channel/cross-channel-receive.js +40 -0
  9. package/dist/src/channel/routes.d.ts +7 -0
  10. package/dist/src/channel/send.js +1 -1
  11. package/dist/src/channel/session.d.ts +47 -1
  12. package/dist/src/channel/session.js +46 -0
  13. package/dist/src/channel/types.d.ts +6 -5
  14. package/dist/src/chunks/{dev-authored-source-watcher-DtLxnrXI.js → dev-authored-source-watcher-j7YWh2Gx.js} +1 -1
  15. package/dist/src/chunks/{host-Dor4C8jo.js → host-C19hLVqS.js} +3 -3
  16. package/dist/src/chunks/{paths-AVYgVLR3.js → paths-Dwv0Eash.js} +21 -21
  17. package/dist/src/chunks/{prewarm-DsMkM8wg.js → prewarm-CQYfka30.js} +1 -1
  18. package/dist/src/cli/commands/info.js +1 -1
  19. package/dist/src/cli/run.js +1 -1
  20. package/dist/src/evals/cli/eval.js +1 -1
  21. package/dist/src/execution/sandbox/bindings/vercel.d.ts +2 -2
  22. package/dist/src/execution/sandbox/bindings/vercel.js +1 -34
  23. package/dist/src/execution/workflow-entry.js +35 -31
  24. package/dist/src/execution/workflow-steps.d.ts +16 -0
  25. package/dist/src/execution/workflow-steps.js +32 -4
  26. package/dist/src/harness/attachment-staging.js +2 -1
  27. package/dist/src/internal/application/package.js +1 -1
  28. package/dist/src/internal/workflow-bundle/vercel-workflow-output.js +0 -2
  29. package/dist/src/public/channels/slack/api.d.ts +13 -33
  30. package/dist/src/public/channels/slack/api.js +32 -94
  31. package/dist/src/public/channels/slack/defaults.js +2 -2
  32. package/dist/src/public/channels/slack/hitl.js +6 -3
  33. package/dist/src/public/channels/slack/inbound.js +1 -1
  34. package/dist/src/public/channels/slack/index.d.ts +2 -2
  35. package/dist/src/public/channels/slack/index.js +1 -0
  36. package/dist/src/public/channels/slack/interactions.js +3 -3
  37. package/dist/src/public/channels/slack/limits.d.ts +19 -0
  38. package/dist/src/public/channels/slack/limits.js +23 -0
  39. package/dist/src/public/channels/slack/mrkdwn.d.ts +38 -0
  40. package/dist/src/public/channels/slack/mrkdwn.js +89 -0
  41. package/dist/src/public/channels/slack/slackChannel.d.ts +5 -3
  42. package/dist/src/public/channels/slack/slackChannel.js +20 -15
  43. package/dist/src/public/channels/twilio/api.d.ts +9 -0
  44. package/dist/src/public/channels/twilio/api.js +11 -0
  45. package/dist/src/public/channels/twilio/index.d.ts +1 -1
  46. package/dist/src/public/channels/twilio/index.js +1 -1
  47. package/dist/src/public/channels/twilio/twilioChannel.d.ts +2 -0
  48. package/dist/src/public/channels/twilio/twilioChannel.js +8 -11
  49. package/dist/src/public/definitions/defineChannel.d.ts +9 -1
  50. package/dist/src/public/definitions/defineChannel.js +7 -11
  51. package/dist/src/public/definitions/sandbox.d.ts +2 -3
  52. package/dist/src/public/sandbox/backends/vercel.d.ts +4 -4
  53. package/dist/src/public/sandbox/backends/vercel.js +2 -2
  54. package/dist/src/public/sandbox/index.d.ts +1 -1
  55. package/dist/src/public/sandbox/vercel-sandbox.d.ts +3 -28
  56. package/dist/src/runtime/types.d.ts +1 -2
  57. package/dist/src/shared/sandbox-backend.d.ts +4 -4
  58. package/dist/src/shared/sandbox-definition.d.ts +6 -6
  59. package/package.json +1 -1
@@ -1 +1 @@
1
- import{n as e}from"../../chunks/paths-AVYgVLR3.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{a as n,n as r,t as i}from"../../chunks/client-CKsU8Li3.js";import{n as a}from"../../chunks/host-Dor4C8jo.js";import{discoverAndImportSuites as o,discoverSuiteFiles as s,importSuiteFile as c}from"../runner/discover.js";import{executeSuite as l}from"../runner/execute-suite.js";import{ConsoleReporter as u}from"../runner/reporters/console.js";var d=n();function f(e,t){e.command(`eval`).description(`Run eval suites against an Ash agent.`).option(`--suite <id...>`,`Suite IDs to run (repeatable)`).option(`--all`,`Run all discovered suites`).option(`--url <url>`,`Remote agent URL (skip local host startup)`).option(`--timeout <ms>`,`Per-case timeout in milliseconds`).option(`--max-concurrency <n>`,`Max concurrent case executions per suite`).option(`--json`,`Output results as JSON`).option(`--list-suites`,`List discovered suites and exit`).option(`--skip-report`,`Skip suite-defined reporters (e.g. Braintrust)`).action(async e=>{await p(e,t)})}async function p(n,r){let i=e();if(t(i),n.listSuites){await y(i,r);return}let s=n.suite,c=await o(i,s);if(c.length===0){s&&s.length>0?r.error(`No suites found matching: ${s.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,d;n.url?d={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),d={kind:`local`,url:u.url});let f=m(d);try{let e=[];for(let t of c){let r=_(t,n),a=v(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await l({suite:r,target:d,reporters:a,appRoot:i,client:f});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function m(e){if(e.kind===`local`)return new i({host:e.url});let t={},n=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return n&&(t[r]=n),new i({auth:h(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function h(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:g}}async function g(){try{let e=(await(0,d.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function _(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function v(e,t){let n=t.json?[]:[new u];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}async function y(e,t){let n=await s(e);if(n.length===0){t.log(`No eval suites found.`);return}t.log(`Found ${n.length} eval suite file(s):\n`);for(let r of n){let n=await c(e,r);t.log(` ${n.id}${n.description?` - ${n.description}`:``}`)}}export{f as registerEvalCommand,p as runEvalCommand};
1
+ import{n as e}from"../../chunks/paths-Dwv0Eash.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{a as n,n as r,t as i}from"../../chunks/client-CKsU8Li3.js";import{n as a}from"../../chunks/host-C19hLVqS.js";import{discoverAndImportSuites as o,discoverSuiteFiles as s,importSuiteFile as c}from"../runner/discover.js";import{executeSuite as l}from"../runner/execute-suite.js";import{ConsoleReporter as u}from"../runner/reporters/console.js";var d=n();function f(e,t){e.command(`eval`).description(`Run eval suites against an Ash agent.`).option(`--suite <id...>`,`Suite IDs to run (repeatable)`).option(`--all`,`Run all discovered suites`).option(`--url <url>`,`Remote agent URL (skip local host startup)`).option(`--timeout <ms>`,`Per-case timeout in milliseconds`).option(`--max-concurrency <n>`,`Max concurrent case executions per suite`).option(`--json`,`Output results as JSON`).option(`--list-suites`,`List discovered suites and exit`).option(`--skip-report`,`Skip suite-defined reporters (e.g. Braintrust)`).action(async e=>{await p(e,t)})}async function p(n,r){let i=e();if(t(i),n.listSuites){await y(i,r);return}let s=n.suite,c=await o(i,s);if(c.length===0){s&&s.length>0?r.error(`No suites found matching: ${s.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,d;n.url?d={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),d={kind:`local`,url:u.url});let f=m(d);try{let e=[];for(let t of c){let r=_(t,n),a=v(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await l({suite:r,target:d,reporters:a,appRoot:i,client:f});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function m(e){if(e.kind===`local`)return new i({host:e.url});let t={},n=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return n&&(t[r]=n),new i({auth:h(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function h(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:g}}async function g(){try{let e=(await(0,d.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function _(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function v(e,t){let n=t.json?[]:[new u];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}async function y(e,t){let n=await s(e);if(n.length===0){t.log(`No eval suites found.`);return}t.log(`Found ${n.length} eval suite file(s):\n`);for(let r of n){let n=await c(e,r);t.log(` ${n.id}${n.description?` - ${n.description}`:``}`)}}export{f as registerEvalCommand,p as runEvalCommand};
@@ -1,7 +1,7 @@
1
1
  import type * as VercelSandboxSdk from "#compiled/@vercel/sandbox/index.js";
2
2
  import type { Sandbox as SdkSandbox } from "#compiled/@vercel/sandbox/index.js";
3
3
  import type { SandboxBackend } from "#public/definitions/sandbox-backend.js";
4
- import type { VercelSandbox, VercelSandboxBootstrapUseOptions, VercelSandboxSessionUseOptions } from "#public/sandbox/vercel-sandbox.js";
4
+ import type { VercelSandboxBootstrapUseOptions, VercelSandboxSessionUseOptions } from "#public/sandbox/vercel-sandbox.js";
5
5
  type VercelSandboxModule = typeof VercelSandboxSdk;
6
6
  /**
7
7
  * User-controllable subset of `Sandbox.create` parameters.
@@ -18,5 +18,5 @@ export interface CreateVercelSandboxBackendInput {
18
18
  /**
19
19
  * Creates the Vercel-backed sandbox backend.
20
20
  */
21
- export declare function createVercelSandboxBackend(input?: CreateVercelSandboxBackendInput): SandboxBackend<VercelSandbox, VercelSandboxBootstrapUseOptions, VercelSandboxSessionUseOptions>;
21
+ export declare function createVercelSandboxBackend(input?: CreateVercelSandboxBackendInput): SandboxBackend<VercelSandboxBootstrapUseOptions, VercelSandboxSessionUseOptions>;
22
22
  export {};
@@ -168,7 +168,7 @@ function createHandle(sandbox, sessionKey) {
168
168
  if (options !== undefined) {
169
169
  await sandbox.update(options);
170
170
  }
171
- return buildVercelSandbox(sandbox, sessionKey);
171
+ return buildSandboxSession(createVercelInternalSandboxSession(sandbox, sessionKey));
172
172
  },
173
173
  async captureState() {
174
174
  return {
@@ -180,39 +180,6 @@ function createHandle(sandbox, sessionKey) {
180
180
  async dispose() { },
181
181
  };
182
182
  }
183
- function buildVercelSandbox(sandbox, id) {
184
- const session = buildSandboxSession(createVercelInternalSandboxSession(sandbox, id));
185
- return {
186
- ...session,
187
- get name() {
188
- return sandbox.name;
189
- },
190
- get persistent() {
191
- return sandbox.persistent;
192
- },
193
- get status() {
194
- return sandbox.status;
195
- },
196
- get networkPolicy() {
197
- return sandbox.networkPolicy;
198
- },
199
- get tags() {
200
- return sandbox.tags;
201
- },
202
- async update(params) {
203
- await sandbox.update(params);
204
- },
205
- async stop(opts) {
206
- await sandbox.stop(opts);
207
- },
208
- async snapshot(opts) {
209
- return await sandbox.snapshot(opts);
210
- },
211
- domain(port) {
212
- return sandbox.domain(port);
213
- },
214
- };
215
- }
216
183
  function createVercelInternalSandboxSession(sandbox, id) {
217
184
  return {
218
185
  id,
@@ -108,16 +108,21 @@ async function runWorkflowLoop(input) {
108
108
  });
109
109
  return { output: currentResult.output ?? "" };
110
110
  }
111
- const parkToken = currentSession.continuationToken || deriveAdapterContinuationToken(currentSerializedContext);
112
- if (!parkToken) {
113
- throw new Error("Cannot park: no continuation token available. The adapter must " +
114
- "provide a token via getContinuationToken() (e.g., by posting the " +
115
- "first message to the platform during the initial turn).");
111
+ // The first turn may have re-keyed the session via
112
+ // `ctx.session.setContinuationToken(...)` (e.g. Slack auto-anchor on
113
+ // first post). `currentSession.continuationToken` already reflects
114
+ // the latest token thanks to `reconcileSessionContinuationToken`
115
+ // inside `durableRunStep`, so creating the park hook here lands at
116
+ // the right token from the start.
117
+ if (!currentSession.continuationToken) {
118
+ throw new Error("Cannot park: no continuation token available. The channel must " +
119
+ "post the first message during the initial turn (anchoring the " +
120
+ "session) or `send()` must be called with an explicit " +
121
+ "continuationToken.");
116
122
  }
117
- const hook = createHook({
118
- token: parkToken,
119
- });
120
- const iterator = hook[Symbol.asyncIterator]();
123
+ let parkToken = currentSession.continuationToken;
124
+ let hook = createHook({ token: parkToken });
125
+ let iterator = hook[Symbol.asyncIterator]();
121
126
  let pendingNext = null;
122
127
  const getNextPromise = () => {
123
128
  pendingNext ??= iterator.next();
@@ -126,6 +131,24 @@ async function runWorkflowLoop(input) {
126
131
  const consumeNext = () => {
127
132
  pendingNext = null;
128
133
  };
134
+ /**
135
+ * Disposes the current park hook and creates a new one at
136
+ * {@link nextToken}. Called after a turn whose handlers
137
+ * re-keyed the session via `setContinuationToken(...)`. In-flight
138
+ * deliveries to the old token after this returns are silently
139
+ * dropped — channels that re-key mid-session must coordinate with
140
+ * their senders so follow-up traffic uses the new token.
141
+ */
142
+ const rekeyHook = async (nextToken) => {
143
+ if (nextToken === parkToken || !nextToken)
144
+ return;
145
+ await closeHookIterator(iterator);
146
+ await disposeHook(hook);
147
+ parkToken = nextToken;
148
+ hook = createHook({ token: parkToken });
149
+ iterator = hook[Symbol.asyncIterator]();
150
+ pendingNext = null;
151
+ };
129
152
  try {
130
153
  while (true) {
131
154
  if (hasPendingRuntimeActionBatch(currentSession)) {
@@ -137,6 +160,7 @@ async function runWorkflowLoop(input) {
137
160
  bufferedDeliveries,
138
161
  getNextPromise,
139
162
  consumeNext,
163
+ rekeyHook,
140
164
  serializedContext: currentSerializedContext,
141
165
  session: currentSession,
142
166
  });
@@ -184,6 +208,7 @@ async function runWorkflowLoop(input) {
184
208
  });
185
209
  return { output: currentResult.output ?? "" };
186
210
  }
211
+ await rekeyHook(currentSession.continuationToken);
187
212
  }
188
213
  }
189
214
  finally {
@@ -292,6 +317,7 @@ async function waitForPendingRuntimeActionResults(input) {
292
317
  });
293
318
  currentSession = proxyResult.session;
294
319
  currentSerializedContext = proxyResult.serializedContext;
320
+ await input.rekeyHook(currentSession.continuationToken);
295
321
  }
296
322
  },
297
323
  session: currentSession,
@@ -456,25 +482,3 @@ async function disposeHook(hook) {
456
482
  }
457
483
  await symbolDispose.call(hook);
458
484
  }
459
- /**
460
- * Derives the continuation token from the serialized adapter state.
461
- *
462
- * Used when no explicit continuation token was provided on RunInput
463
- * (schedule-initiated sessions). The token is derived from the
464
- * adapter's serialized resume identity — for Slack, this is the
465
- * continuation token `slack:{channelId}:{threadTs}` when threadTs is populated.
466
- */
467
- function deriveAdapterContinuationToken(serializedContext) {
468
- const channel = serializedContext["ash.channel"];
469
- if (!channel?.state)
470
- return undefined;
471
- const serializedThread = channel.state.serializedThread;
472
- if (!serializedThread?.id)
473
- return undefined;
474
- const id = serializedThread.id;
475
- const lastColon = id.lastIndexOf(":");
476
- if (lastColon === -1)
477
- return undefined;
478
- const threadTs = id.slice(lastColon + 1);
479
- return threadTs.length > 0 ? id : undefined;
480
- }
@@ -1,4 +1,5 @@
1
1
  import type { DeliverPayload, HookPayload, SessionAuthContext, SubagentInputRequestHookPayload } from "#channel/types.js";
2
+ import { deserializeContext } from "#context/serialize.js";
2
3
  import type { HarnessSession } from "#harness/types.js";
3
4
  import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
4
5
  import type { DurableStepResult } from "#execution/types.js";
@@ -6,6 +7,21 @@ import type { DurableStepResult } from "#execution/types.js";
6
7
  * Runs one atomic harness step inside a durable `"use step"` boundary.
7
8
  */
8
9
  export declare function durableRunStep(serializedContext: Record<string, unknown>, session: HarnessSession, input: HookPayload | undefined): Promise<DurableStepResult>;
10
+ /**
11
+ * Reconciles `session.continuationToken` with the live
12
+ * `ContinuationTokenKey` value in context.
13
+ *
14
+ * Channels mutate the token by calling
15
+ * `ctx.session.setContinuationToken(...)`, which writes through to
16
+ * `ContinuationTokenKey`. The serialized context picks the new value
17
+ * up automatically, but the in-memory {@link HarnessSession} the
18
+ * workflow body carries needs to be re-stamped so its `parkToken`
19
+ * read after the step reflects the re-key.
20
+ *
21
+ * Exported for unit testing; the workflow runtime is the only
22
+ * production caller.
23
+ */
24
+ export declare function reconcileSessionContinuationToken(ctx: Awaited<ReturnType<typeof deserializeContext>>, session: HarnessSession): HarnessSession;
9
25
  /**
10
26
  * Starts every pending runtime action for the parked parent session.
11
27
  *
@@ -3,7 +3,7 @@ import { buildAdapterContext } from "#channel/adapter-context.js";
3
3
  import { callAdapterEventHandler, defaultDeliverResult } from "#channel/adapter.js";
4
4
  import { toContextAccessor } from "#context/container.js";
5
5
  import { dispatchStreamEventHooks, runHookLifecycleStep } from "#context/hook-lifecycle.js";
6
- import { AuthKey, BundleKey, CapabilitiesKey, ChannelKey, InitiatorAuthKey, ModeKey, } from "#context/keys.js";
6
+ import { AuthKey, BundleKey, CapabilitiesKey, ChannelKey, ContinuationTokenKey, InitiatorAuthKey, ModeKey, } from "#context/keys.js";
7
7
  import { runStep } from "#context/run-step.js";
8
8
  import { deserializeContext, serializeContext } from "#context/serialize.js";
9
9
  import { getHarnessEmissionState, isHarnessBetweenTurns } from "#harness/emission.js";
@@ -65,6 +65,7 @@ export async function durableRunStep(serializedContext, session, input) {
65
65
  // only edits a message). Persist any context mutations and re-park
66
66
  // without running a model turn.
67
67
  if (input?.kind === "deliver" && resolved === undefined) {
68
+ const rekeyed = reconcileSessionContinuationToken(ctx, session);
68
69
  const nextSerializedContext = serializeContext(ctx);
69
70
  const writable = getWritable();
70
71
  const writer = writable.getWriter();
@@ -72,7 +73,7 @@ export async function durableRunStep(serializedContext, session, input) {
72
73
  return {
73
74
  action: "park",
74
75
  serializedContext: nextSerializedContext,
75
- session,
76
+ session: rekeyed,
76
77
  };
77
78
  }
78
79
  const writable = getWritable();
@@ -84,7 +85,7 @@ export async function durableRunStep(serializedContext, session, input) {
84
85
  await writer.write(encodeMessageStreamEvent(timestampHandleMessageStreamEvent(toEmit)));
85
86
  await dispatchStreamEventHooks({ ctx, registry: hookRegistry, event: toEmit });
86
87
  };
87
- const stepResult = await runStep(ctx, session, async (enrichedSession) => {
88
+ let stepResult = await runStep(ctx, session, async (enrichedSession) => {
88
89
  const bundle = ctx.require(BundleKey);
89
90
  const capabilities = ctx.get(CapabilitiesKey);
90
91
  const mode = ctx.require(ModeKey);
@@ -129,7 +130,13 @@ export async function durableRunStep(serializedContext, session, input) {
129
130
  }
130
131
  return runHarnessStep(enrichedSession, resolved);
131
132
  });
133
+ // Reconcile the session's continuation token with `ContinuationTokenKey`
134
+ // in case a handler called `session.setContinuationToken(...)` during
135
+ // the turn (e.g. Slack's auto-anchor on first post). Idempotent when
136
+ // the token is unchanged.
137
+ const rekeyed = reconcileSessionContinuationToken(ctx, stepResult.session);
132
138
  const nextSerializedContext = serializeContext(ctx);
139
+ stepResult = { ...stepResult, session: rekeyed };
133
140
  // Interactive OAuth. When the step left one or more pending
134
141
  // connection tool calls (placeholders written to history) and at
135
142
  // least one matching pending authorization, the workflow body owns
@@ -177,6 +184,26 @@ export async function durableRunStep(serializedContext, session, input) {
177
184
  session: stepResult.session,
178
185
  };
179
186
  }
187
+ /**
188
+ * Reconciles `session.continuationToken` with the live
189
+ * `ContinuationTokenKey` value in context.
190
+ *
191
+ * Channels mutate the token by calling
192
+ * `ctx.session.setContinuationToken(...)`, which writes through to
193
+ * `ContinuationTokenKey`. The serialized context picks the new value
194
+ * up automatically, but the in-memory {@link HarnessSession} the
195
+ * workflow body carries needs to be re-stamped so its `parkToken`
196
+ * read after the step reflects the re-key.
197
+ *
198
+ * Exported for unit testing; the workflow runtime is the only
199
+ * production caller.
200
+ */
201
+ export function reconcileSessionContinuationToken(ctx, session) {
202
+ const next = ctx.get(ContinuationTokenKey);
203
+ if (next === undefined || next === session.continuationToken)
204
+ return session;
205
+ return { ...session, continuationToken: next };
206
+ }
180
207
  /**
181
208
  * Starts every pending runtime action for the parked parent session.
182
209
  *
@@ -333,11 +360,12 @@ export async function runProxyInputRequestStep(input) {
333
360
  // adapter and later text-reply deliveries miss the cached batch.
334
361
  ctx.set(ChannelKey, { ...adapter, state: { ...adapterCtx.state } });
335
362
  const nextSerializedContext = serializeContext(ctx);
336
- const nextSession = upsertProxyInputRequests({
363
+ const sessionWithProxyEntries = upsertProxyInputRequests({
337
364
  entries: proxyResult.entries,
338
365
  forChildContinuationToken: input.hookPayload.childContinuationToken,
339
366
  session: proxyResult.session,
340
367
  });
368
+ const nextSession = reconcileSessionContinuationToken(ctx, sessionWithProxyEntries);
341
369
  return {
342
370
  serializedContext: nextSerializedContext,
343
371
  session: nextSession,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { buildAdapterContext } from "#channel/adapter-context.js";
4
4
  import { getAdapterKind } from "#channel/adapter.js";
5
+ import { buildSessionHandle } from "#channel/session.js";
5
6
  import { loadContext } from "#context/container.js";
6
7
  import { ChannelKey, SandboxKey } from "#context/keys.js";
7
8
  import { fileDataToBytes } from "#internal/attachments/data.js";
@@ -83,7 +84,7 @@ export async function stageAttachmentsToSandbox(message) {
83
84
  const adapter = container.get(ChannelKey);
84
85
  const adapterCtx = adapter
85
86
  ? buildAdapterContext(adapter, container)
86
- : { ctx: container, state: {} };
87
+ : { ctx: container, state: {}, session: buildSessionHandle(container) };
87
88
  return stageAttachmentsForAdapter(message, sandbox, adapterCtx);
88
89
  }
89
90
  /**
@@ -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.18.1";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.18.3";
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",
@@ -11,7 +11,6 @@ export const WORKFLOW_STEP_EXTERNAL_PACKAGES = ["@mongodb-js/zstd", "node-liblzm
11
11
  * Nitro performs the final bundling/tracing pass for hosted output.
12
12
  */
13
13
  export const WORKFLOW_BUILDER_DEFERRED_PACKAGES = ["@chat-adapter/slack", "chat"];
14
- const WORKFLOW_FUNCTION_NODE_OPTIONS = "--experimental-require-module";
15
14
  /**
16
15
  * Builds the environment block every generated Vercel workflow function needs.
17
16
  */
@@ -20,7 +19,6 @@ export function createWorkflowFunctionEnvironment(environment) {
20
19
  if (isRecord(environment)) {
21
20
  Object.assign(nextEnvironment, environment);
22
21
  }
23
- nextEnvironment.NODE_OPTIONS = WORKFLOW_FUNCTION_NODE_OPTIONS;
24
22
  return nextEnvironment;
25
23
  }
26
24
  function isRecord(value) {
@@ -22,12 +22,13 @@ import { type CardElement, type FileUpload } from "#compiled/chat/index.js";
22
22
  */
23
23
  export type SlackBotToken = string | (() => string | Promise<string>);
24
24
  /**
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.
25
+ * Builds the Slack channel-local continuation token
26
+ * (`<channelId>:<threadTs>`). Route `send()` namespaces this with the
27
+ * channel name before handing it to the runtime
28
+ * (`slack:<channelId>:<threadTs>`). `threadTs` may be empty for
29
+ * threadless sessions; the channel auto-anchors on its first post.
29
30
  */
30
- export declare function encodeSlackContinuationToken(channelId: string, threadTs: string): string;
31
+ export declare function slackContinuationToken(channelId: string, threadTs: string): string;
31
32
  /**
32
33
  * Materializes a {@link SlackBotToken} to a concrete string. Falls
33
34
  * back to `process.env.SLACK_BOT_TOKEN` when no value was passed.
@@ -267,39 +268,18 @@ export interface SlackBinding {
267
268
  readonly slack: SlackHandle;
268
269
  }
269
270
  /**
270
- * Internal-only constructor for the `{ thread, slack }` pair. Returns
271
- * two objects that share one bot-token resolver / `request()` closure
272
- * and one `recentMessages` array (mutated by `refresh`).
271
+ * Internal constructor for the `{ thread, slack }` pair.
272
+ *
273
+ * Auto-anchor: when the binding starts without a `threadTs`, the first
274
+ * `chat.postMessage` adopts its own `ts` as the thread root; the live
275
+ * `threadTs` is updated and `onThreadTsChanged` fires so the caller can persist
276
+ * the anchor. Ephemerals and files-only posts do not anchor.
273
277
  */
274
278
  export declare function buildSlackBinding(input: {
275
279
  readonly botToken: SlackBotToken | undefined;
276
280
  readonly channelId: string;
277
281
  readonly threadTs: string;
278
282
  readonly teamId: string | undefined;
283
+ readonly onThreadTsChanged?: (ts: string) => void;
279
284
  }): SlackBinding;
280
- /**
281
- * Best-effort GFM → Slack mrkdwn converter used only in contexts that
282
- * do not support `markdown_text` (e.g. `files.completeUploadExternal`'s
283
- * `initial_comment` field).
284
- *
285
- * The main `{ markdown }` post path sends `markdown_text` directly
286
- * to `chat.postMessage` and does not go through this converter.
287
- */
288
- export declare function gfmToSlackMrkdwn(input: string): string;
289
- /**
290
- * Best-effort Slack mrkdwn → GFM converter applied to the text of
291
- * every inbound Slack message before the harness sees it.
292
- *
293
- * - `<@U123>` → `@U123`
294
- * - `<#C123|name>` → `#name` (or `#C123` when no name)
295
- * - `<!channel>` etc. → `@channel`
296
- * - `<https://x|label>` → `[label](https://x)`
297
- * - `<https://x>` → `https://x`
298
- * - `*bold*` (paired) → `**bold**`
299
- * - `~strike~` (paired) → `~~strike~~`
300
- *
301
- * Inline `_italic_` and code spans pass through unchanged because both
302
- * formats render them identically.
303
- */
304
- export declare function slackMrkdwnToGfm(input: string): string;
305
285
  export {};
@@ -18,14 +18,16 @@ import { isCardElement } from "#compiled/chat/index.js";
18
18
  import { createLogger } from "#internal/logging.js";
19
19
  import { encodeSlackApiBody } from "#public/channels/slack/api-encoding.js";
20
20
  import { cardToBlocks, cardToFallbackText } from "#public/channels/slack/blocks.js";
21
+ import { gfmToSlackMrkdwn, rewriteBareMentions, slackMrkdwnToGfm, } from "#public/channels/slack/mrkdwn.js";
21
22
  const log = createLogger("slack.api");
22
23
  /**
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.
24
+ * Builds the Slack channel-local continuation token
25
+ * (`<channelId>:<threadTs>`). Route `send()` namespaces this with the
26
+ * channel name before handing it to the runtime
27
+ * (`slack:<channelId>:<threadTs>`). `threadTs` may be empty for
28
+ * threadless sessions; the channel auto-anchors on its first post.
27
29
  */
28
- export function encodeSlackContinuationToken(channelId, threadTs) {
30
+ export function slackContinuationToken(channelId, threadTs) {
29
31
  return `${channelId}:${threadTs}`;
30
32
  }
31
33
  /**
@@ -67,19 +69,29 @@ export function createSlackRequester(botToken) {
67
69
  return (operation, body) => callSlackApi({ botToken, operation, body });
68
70
  }
69
71
  /**
70
- * Internal-only constructor for the `{ thread, slack }` pair. Returns
71
- * two objects that share one bot-token resolver / `request()` closure
72
- * and one `recentMessages` array (mutated by `refresh`).
72
+ * Internal constructor for the `{ thread, slack }` pair.
73
+ *
74
+ * Auto-anchor: when the binding starts without a `threadTs`, the first
75
+ * `chat.postMessage` adopts its own `ts` as the thread root; the live
76
+ * `threadTs` is updated and `onThreadTsChanged` fires so the caller can persist
77
+ * the anchor. Ephemerals and files-only posts do not anchor.
73
78
  */
74
79
  export function buildSlackBinding(input) {
75
80
  const request = createSlackRequester(input.botToken);
76
81
  const messages = [];
82
+ let currentThreadTs = input.threadTs;
83
+ function handleMessageTs(ts) {
84
+ if (currentThreadTs || ts === currentThreadTs)
85
+ return;
86
+ currentThreadTs = ts;
87
+ input.onThreadTsChanged?.(ts);
88
+ }
77
89
  async function uploadFiles(files, options) {
78
90
  if (files.length === 0) {
79
91
  return { fileIds: [], raw: { ok: true } };
80
92
  }
81
93
  const channelId = options?.channelId ?? input.channelId;
82
- const threadTs = options?.threadTs ?? input.threadTs;
94
+ const threadTs = options?.threadTs ?? currentThreadTs;
83
95
  const token = await resolveSlackBotToken(input.botToken);
84
96
  const fileIds = [];
85
97
  for (const file of files) {
@@ -151,12 +163,13 @@ export function buildSlackBinding(input) {
151
163
  : "";
152
164
  return { id, raw: result.raw };
153
165
  }
154
- const body = buildPostMessageBody(message, input.channelId, input.threadTs);
166
+ const body = buildPostMessageBody(message, input.channelId, currentThreadTs);
155
167
  const response = await request("chat.postMessage", body);
156
168
  if (response.ok !== true) {
157
169
  throw new Error(`Slack chat.postMessage failed: ${response.error ?? "unknown_error"}`);
158
170
  }
159
171
  const id = typeof response.ts === "string" ? response.ts : "";
172
+ handleMessageTs(id);
160
173
  // blocks / card + files: structured message lands first, then upload
161
174
  // files as a follow-up post in the same thread.
162
175
  if (files.length > 0 && hasStructured) {
@@ -171,7 +184,7 @@ export function buildSlackBinding(input) {
171
184
  },
172
185
  async postEphemeral(userId, rawMessage) {
173
186
  const message = normalizePostInput(rawMessage);
174
- const body = buildPostMessageBody(message, input.channelId, input.threadTs);
187
+ const body = buildPostMessageBody(message, input.channelId, currentThreadTs);
175
188
  body.user = userId;
176
189
  const response = await request("chat.postEphemeral", body);
177
190
  if (response.ok !== true) {
@@ -181,12 +194,12 @@ export function buildSlackBinding(input) {
181
194
  return { id, raw: response };
182
195
  },
183
196
  async startTyping(status) {
184
- if (!input.channelId || !input.threadTs)
197
+ if (!input.channelId || !currentThreadTs)
185
198
  return;
186
199
  try {
187
200
  const body = {
188
201
  channel_id: input.channelId,
189
- thread_ts: input.threadTs,
202
+ thread_ts: currentThreadTs,
190
203
  status: status ?? "",
191
204
  };
192
205
  if (status !== undefined && status.length > 0) {
@@ -205,12 +218,12 @@ export function buildSlackBinding(input) {
205
218
  },
206
219
  async refresh() {
207
220
  messages.length = 0;
208
- if (!input.channelId || !input.threadTs)
221
+ if (!input.channelId || !currentThreadTs)
209
222
  return;
210
223
  try {
211
224
  const response = await request("conversations.replies", {
212
225
  channel: input.channelId,
213
- ts: input.threadTs,
226
+ ts: currentThreadTs,
214
227
  limit: 50,
215
228
  });
216
229
  if (response.ok !== true || !Array.isArray(response.messages)) {
@@ -218,7 +231,7 @@ export function buildSlackBinding(input) {
218
231
  return;
219
232
  }
220
233
  for (const raw of response.messages) {
221
- messages.push(parseThreadMessage(raw, input.threadTs));
234
+ messages.push(parseThreadMessage(raw, currentThreadTs));
222
235
  }
223
236
  }
224
237
  catch (error) {
@@ -231,7 +244,9 @@ export function buildSlackBinding(input) {
231
244
  };
232
245
  const slack = {
233
246
  channelId: input.channelId,
234
- threadTs: input.threadTs,
247
+ get threadTs() {
248
+ return currentThreadTs;
249
+ },
235
250
  teamId: input.teamId,
236
251
  request,
237
252
  uploadFiles,
@@ -299,70 +314,6 @@ function parseThreadMessage(raw, threadRootTs) {
299
314
  raw,
300
315
  };
301
316
  }
302
- const BARE_MENTION_RE = /(?<![<\w])@(\w+)/gu;
303
- function rewriteBareMentions(text) {
304
- return text.replace(BARE_MENTION_RE, "<@$1>");
305
- }
306
- /**
307
- * Best-effort GFM → Slack mrkdwn converter used only in contexts that
308
- * do not support `markdown_text` (e.g. `files.completeUploadExternal`'s
309
- * `initial_comment` field).
310
- *
311
- * The main `{ markdown }` post path sends `markdown_text` directly
312
- * to `chat.postMessage` and does not go through this converter.
313
- */
314
- export function gfmToSlackMrkdwn(input) {
315
- const segments = splitCodeFences(input);
316
- return segments
317
- .map((segment) => (segment.kind === "code" ? segment.text : convertInline(segment.text)))
318
- .join("");
319
- }
320
- /**
321
- * Best-effort Slack mrkdwn → GFM converter applied to the text of
322
- * every inbound Slack message before the harness sees it.
323
- *
324
- * - `<@U123>` → `@U123`
325
- * - `<#C123|name>` → `#name` (or `#C123` when no name)
326
- * - `<!channel>` etc. → `@channel`
327
- * - `<https://x|label>` → `[label](https://x)`
328
- * - `<https://x>` → `https://x`
329
- * - `*bold*` (paired) → `**bold**`
330
- * - `~strike~` (paired) → `~~strike~~`
331
- *
332
- * Inline `_italic_` and code spans pass through unchanged because both
333
- * formats render them identically.
334
- */
335
- export function slackMrkdwnToGfm(input) {
336
- const segments = splitCodeFences(input);
337
- return segments
338
- .map((segment) => (segment.kind === "code" ? segment.text : decodeInline(segment.text)))
339
- .join("");
340
- }
341
- function splitCodeFences(input) {
342
- const segments = [];
343
- const fenceRe = /```[\s\S]*?```|`[^`\n]+`/gu;
344
- let lastIndex = 0;
345
- for (const match of input.matchAll(fenceRe)) {
346
- const start = match.index ?? 0;
347
- if (start > lastIndex) {
348
- segments.push({ kind: "text", text: input.slice(lastIndex, start) });
349
- }
350
- segments.push({ kind: "code", text: match[0] });
351
- lastIndex = start + match[0].length;
352
- }
353
- if (lastIndex < input.length) {
354
- segments.push({ kind: "text", text: input.slice(lastIndex) });
355
- }
356
- return segments;
357
- }
358
- function convertInline(input) {
359
- let out = input;
360
- out = out.replace(/\*\*([^*\n]+)\*\*/gu, "*$1*");
361
- out = out.replace(/__([^_\n]+)__/gu, "*$1*");
362
- out = out.replace(/~~([^~\n]+)~~/gu, "~$1~");
363
- out = out.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/gu, "<$2|$1>");
364
- return out;
365
- }
366
317
  /**
367
318
  * Normalize a {@link FileUpload.data} value (`Buffer | Blob | ArrayBuffer`) to
368
319
  * a contiguous `Buffer` we can both POST and length-prefix without
@@ -380,16 +331,3 @@ async function readFileBytes(data) {
380
331
  }
381
332
  throw new Error("FileUpload.data must be a Buffer, ArrayBuffer, or Blob.");
382
333
  }
383
- function decodeInline(input) {
384
- let out = input;
385
- out = out.replace(/<!(channel|here|everyone)>/gu, "@$1");
386
- out = out.replace(/<@([A-Z0-9]+)\|([^>]+)>/gu, "@$2");
387
- out = out.replace(/<@([A-Z0-9]+)>/gu, "@$1");
388
- out = out.replace(/<#([A-Z0-9]+)\|([^>]+)>/gu, "#$2");
389
- out = out.replace(/<#([A-Z0-9]+)>/gu, "#$1");
390
- out = out.replace(/<(https?:\/\/[^|>\s]+)\|([^>]+)>/gu, "[$2]($1)");
391
- out = out.replace(/<(https?:\/\/[^>\s]+)>/gu, "$1");
392
- out = out.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/gu, "$1**$2**");
393
- out = out.replace(/(^|[^~])~([^~\n]+)~(?!~)/gu, "$1~~$2~~");
394
- return out;
395
- }
@@ -1,7 +1,7 @@
1
1
  import { createLogger, extractErrorId, formatErrorHint } from "#internal/logging.js";
2
2
  import { buildAuthCompletedText, buildAuthEphemeralBlocks, buildAuthRequiredPublicText, formatConnectionDisplayName, } from "#public/channels/slack/connections.js";
3
3
  import { renderInputRequestBlocks } from "#public/channels/slack/hitl.js";
4
- import { truncateTypingStatus } from "#public/channels/slack/limits.js";
4
+ import { truncateMessageText, truncateTypingStatus } from "#public/channels/slack/limits.js";
5
5
  const log = createLogger("slack.defaults");
6
6
  /**
7
7
  * Workspace-scoped projection of the Slack actor that produced
@@ -84,7 +84,7 @@ export function defaultInputRequestedHandler() {
84
84
  return async (data, ctx) => {
85
85
  if (data.requests.length === 0)
86
86
  return;
87
- const promptText = data.requests.map((r) => r.prompt).join("\n");
87
+ const promptText = truncateMessageText(data.requests.map((r) => r.prompt).join("\n"));
88
88
  await ctx.thread.post({
89
89
  blocks: data.requests.flatMap(renderInputRequestBlocks),
90
90
  text: promptText,