@vellumai/assistant 0.11.5 → 0.11.6-staging.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.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -11,18 +11,17 @@
11
11
  import type {
12
12
  ChannelDeliveryResult,
13
13
  ChannelReplyPayload,
14
- SlackStreamOp,
14
+ StreamOp,
15
15
  } from "@vellumai/gateway-client";
16
16
 
17
17
  import { a2aTransport } from "./a2a/transport.js";
18
18
  import type { DirectDeliveryChannel } from "./callback-routing.js";
19
19
  import { channelForCallback } from "./callback-routing.js";
20
20
  import type {
21
+ ActivityTarget,
21
22
  CallbackContext,
22
23
  ChannelTransport,
23
24
  EditTarget,
24
- ReactionTarget,
25
- ThreadStatus,
26
25
  } from "./channel-transport.js";
27
26
  import { discordTransport } from "./discord/transport.js";
28
27
  import { slackTransport } from "./slack/transport.js";
@@ -53,40 +52,44 @@ export function getTransportForCallback(
53
52
  }
54
53
 
55
54
  /**
56
- * Whether the channel this callback addresses can show a working indicator.
55
+ * Whether the channel this callback addresses can show how busy the assistant
56
+ * is.
57
57
  *
58
58
  * Asks the transport rather than the channel id, so a channel that gains the
59
59
  * method starts being asked without a caller being told about it.
60
60
  */
61
- export function supportsChannelTyping(callbackUrl: string): boolean {
62
- return getTransportForCallback(callbackUrl)?.typing !== undefined;
61
+ export function supportsChannelActivity(callbackUrl: string): boolean {
62
+ return getTransportForCallback(callbackUrl)?.setActivity !== undefined;
63
63
  }
64
64
 
65
65
  /**
66
- * Show that the assistant is working on the channel this callback addresses.
66
+ * How often this channel's busy indicator has to be re-asserted, or
67
+ * `undefined` when it holds until changed and one call is enough.
68
+ */
69
+ export function channelActivityRefreshMs(
70
+ callbackUrl: string,
71
+ ): number | undefined {
72
+ return getTransportForCallback(callbackUrl)?.activityRefreshMs;
73
+ }
74
+
75
+ /**
76
+ * Show how busy the assistant is on the channel this callback addresses.
67
77
  *
68
78
  * Resolves to nothing when the channel has no such affordance, which is the
69
79
  * ordinary case rather than a failure: the indicator is decoration, and a
70
80
  * channel that cannot show one is not degraded by its absence.
71
81
  */
72
- export async function sendChannelTyping(
82
+ export async function setChannelActivity(
73
83
  callbackUrl: string,
74
- chatId: string,
84
+ target: ActivityTarget,
75
85
  ): Promise<ChannelDeliveryResult> {
76
86
  const transport = getTransportForCallback(callbackUrl);
77
- if (!transport?.typing) {
87
+ if (!transport?.setActivity) {
78
88
  return { ok: true };
79
89
  }
80
- return transport.typing(callbackContext(callbackUrl), chatId);
90
+ return transport.setActivity(callbackContext(callbackUrl), target);
81
91
  }
82
92
 
83
- /**
84
- * Add or remove one of the assistant's own reactions on a message.
85
- *
86
- * Resolves to nothing when the channel has none, the same as typing: a
87
- * reaction is an acknowledgement, and a channel that cannot show one is not a
88
- * failed delivery.
89
- */
90
93
  /**
91
94
  * Replace a message the assistant already sent.
92
95
  *
@@ -105,34 +108,6 @@ export async function editChannelMessage(
105
108
  return transport.edit(callbackContext(callbackUrl), target);
106
109
  }
107
110
 
108
- export async function sendChannelReaction(
109
- callbackUrl: string,
110
- target: ReactionTarget,
111
- ): Promise<ChannelDeliveryResult> {
112
- const transport = getTransportForCallback(callbackUrl);
113
- if (!transport?.react) {
114
- return { ok: true };
115
- }
116
- return transport.react(callbackContext(callbackUrl), target);
117
- }
118
-
119
- /**
120
- * Set or clear the channel's status surface.
121
- *
122
- * Resolves to nothing when the channel holds none, so a caller does not have
123
- * to know which channels do.
124
- */
125
- export async function setChannelThreadStatus(
126
- callbackUrl: string,
127
- status: ThreadStatus,
128
- ): Promise<ChannelDeliveryResult> {
129
- const transport = getTransportForCallback(callbackUrl);
130
- if (!transport?.setThreadStatus) {
131
- return { ok: true };
132
- }
133
- return transport.setThreadStatus(callbackContext(callbackUrl), status);
134
- }
135
-
136
111
  /**
137
112
  * Advance a streamed reply on the channel this callback addresses.
138
113
  *
@@ -142,7 +117,7 @@ export async function setChannelThreadStatus(
142
117
  export async function sendChannelStreamOp(
143
118
  callbackUrl: string,
144
119
  chatId: string,
145
- op: SlackStreamOp,
120
+ op: StreamOp,
146
121
  ): Promise<ChannelDeliveryResult> {
147
122
  const transport = getTransportForCallback(callbackUrl);
148
123
  if (!transport?.streamReply) {
@@ -28,56 +28,113 @@ mock.module("./api.js", () => ({
28
28
  // the (unmocked) shared transport, so thrown test errors must be real
29
29
  // instances or every branch under test would silently take the generic path.
30
30
  const { SlackApiError } = await import("./web-api-transport.js");
31
- const { sendSlackAssistantThreadStatus, sendSlackReply, updateSlackMessage } =
31
+ const { sendSlackAgentSessionStatus, sendSlackReply, updateSlackMessage } =
32
32
  await import("./send.js");
33
33
 
34
- describe("sendSlackAssistantThreadStatus", () => {
34
+ describe("sendSlackAgentSessionStatus", () => {
35
+ const threadTs = "1700000000.000100";
36
+
35
37
  beforeEach(() => {
36
38
  callSlackApiMock.mockReset();
37
39
  callSlackApiMock.mockImplementation(async () => ({ ok: true }));
38
40
  });
39
41
 
40
- test("serializes loading messages for Slack assistant thread status", async () => {
41
- await sendSlackAssistantThreadStatus(
42
- "C123",
43
- "1700000000.000100",
44
- "is working...",
45
- ["Reading files", "Running tests"],
46
- );
42
+ // Every phase, so a mapping that loses one fails here rather than showing
43
+ // the wrong thing in Slack. `suspended` is the one with teeth: an approval
44
+ // is waiting on a person, and Slack renders that differently from a turn
45
+ // that is still running.
46
+ test.each([
47
+ ["idle", "active"],
48
+ ["thinking", "processing"],
49
+ ["streaming", "processing"],
50
+ ["tool_running", "processing"],
51
+ ["awaiting_confirmation", "suspended"],
52
+ ] as const)("sends %s as the %s session status", async (phase, status) => {
53
+ await sendSlackAgentSessionStatus({ channel: "C123", phase, threadTs });
47
54
 
48
55
  expect(callSlackApiMock).toHaveBeenCalledTimes(1);
49
- expect(callSlackApiMock).toHaveBeenCalledWith(
50
- "assistant.threads.setStatus",
51
- {
52
- channel_id: "C123",
53
- thread_ts: "1700000000.000100",
54
- status: "is working...",
55
- loading_messages: ["Reading files", "Running tests"],
56
- },
57
- );
56
+ expect(callSlackApiMock).toHaveBeenCalledWith("agents.sessions.setStatus", {
57
+ channel_id: "C123",
58
+ status,
59
+ thread_ts: threadTs,
60
+ });
61
+ });
62
+
63
+ test("carries the initiator, which Slack reads only when it opens the session", async () => {
64
+ await sendSlackAgentSessionStatus({
65
+ channel: "C123",
66
+ phase: "thinking",
67
+ threadTs,
68
+ initiatorUserId: "U0READER",
69
+ });
70
+
71
+ expect(callSlackApiMock).toHaveBeenCalledWith("agents.sessions.setStatus", {
72
+ channel_id: "C123",
73
+ status: "processing",
74
+ thread_ts: threadTs,
75
+ initiator_user_id: "U0READER",
76
+ });
77
+ });
78
+
79
+ test("omits thread_ts in a conversation the app has not threaded", async () => {
80
+ await sendSlackAgentSessionStatus({ channel: "D123", phase: "thinking" });
81
+
82
+ expect(callSlackApiMock).toHaveBeenCalledWith("agents.sessions.setStatus", {
83
+ channel_id: "D123",
84
+ status: "processing",
85
+ });
58
86
  });
59
87
 
60
- test("falls back to the reaction path when status API delivery fails", async () => {
88
+ test("falls back to adding a reaction when the status call fails", async () => {
61
89
  callSlackApiMock
62
90
  .mockImplementationOnce(async () => {
63
91
  throw new Error("missing_scope");
64
92
  })
65
93
  .mockImplementationOnce(async () => ({ ok: true }));
66
94
 
67
- await sendSlackAssistantThreadStatus(
68
- "C123",
69
- "1700000000.000100",
70
- "is working...",
71
- ["Reading files"],
72
- );
95
+ await sendSlackAgentSessionStatus({
96
+ channel: "C123",
97
+ phase: "thinking",
98
+ threadTs,
99
+ });
73
100
 
74
101
  expect(callSlackApiMock).toHaveBeenCalledTimes(2);
75
102
  expect(callSlackApiMock).toHaveBeenNthCalledWith(2, "reactions.add", {
76
103
  channel: "C123",
77
104
  name: "eyes",
78
- timestamp: "1700000000.000100",
105
+ timestamp: threadTs,
79
106
  });
80
107
  });
108
+
109
+ test("falls back to removing the reaction once the turn is no longer running", async () => {
110
+ callSlackApiMock
111
+ .mockImplementationOnce(async () => {
112
+ throw new Error("missing_scope");
113
+ })
114
+ .mockImplementationOnce(async () => ({ ok: true }));
115
+
116
+ await sendSlackAgentSessionStatus({
117
+ channel: "C123",
118
+ phase: "idle",
119
+ threadTs,
120
+ });
121
+
122
+ expect(callSlackApiMock).toHaveBeenNthCalledWith(2, "reactions.remove", {
123
+ channel: "C123",
124
+ name: "eyes",
125
+ timestamp: threadTs,
126
+ });
127
+ });
128
+
129
+ test("stays quiet when the status fails and there is nothing to react to", async () => {
130
+ callSlackApiMock.mockImplementationOnce(async () => {
131
+ throw new Error("missing_scope");
132
+ });
133
+
134
+ await sendSlackAgentSessionStatus({ channel: "D123", phase: "thinking" });
135
+
136
+ expect(callSlackApiMock).toHaveBeenCalledTimes(1);
137
+ });
81
138
  });
82
139
 
83
140
  describe("updateSlackMessage", () => {
@@ -2,17 +2,18 @@
2
2
  * Slack outbound message orchestration.
3
3
  *
4
4
  * Handles text + Block Kit delivery, message updates, approval prompts,
5
- * typing indicators, reactions, thread status, ephemeral messages, and
6
- * attachments by calling the Slack Web API directly via ./api.ts.
5
+ * agent session status, reactions, ephemeral messages, and attachments by
6
+ * calling the Slack Web API directly via ./api.ts.
7
7
  */
8
8
 
9
9
  import type { Button, KnownBlock } from "@slack/types";
10
10
  import type {
11
11
  ApprovalUIMetadata,
12
12
  MessageAudience,
13
- SlackStreamOp,
13
+ StreamOp,
14
14
  } from "@vellumai/gateway-client";
15
15
 
16
+ import type { AssistantActivityPhase } from "../../../api/index.js";
16
17
  import { getAttachmentContent } from "../../../persistence/attachments-store.js";
17
18
  import type { RuntimeAttachmentMetadata } from "../../../runtime/http-types.js";
18
19
  import { getLogger } from "../../../util/logger.js";
@@ -26,6 +27,7 @@ import {
26
27
  uploadToSlackUrl,
27
28
  } from "./api.js";
28
29
  import { renderSlackBlocks } from "./render.js";
30
+ import { toSlackStreamTasks } from "./stream-tasks.js";
29
31
  import { SlackApiError } from "./web-api-transport.js";
30
32
 
31
33
  const log = getLogger("slack-send");
@@ -209,11 +211,6 @@ function buildApprovalFallbackText(
209
211
  return text.includes(instructions) ? text : `${text}\n\n${instructions}`;
210
212
  }
211
213
 
212
- /**
213
- * Post a Slack text message with optional Block Kit formatting.
214
- *
215
- * Always posts. Replacing an existing message is {@link updateSlackMessage}.
216
- */
217
214
  /**
218
215
  * Replace a Slack message in place via `chat.update`.
219
216
  *
@@ -244,6 +241,11 @@ export async function updateSlackMessage(
244
241
  return result;
245
242
  }
246
243
 
244
+ /**
245
+ * Post a Slack text message with optional Block Kit formatting.
246
+ *
247
+ * Always posts. Replacing an existing message is {@link updateSlackMessage}.
248
+ */
247
249
  export async function sendSlackReply(
248
250
  chatId: string,
249
251
  text: string,
@@ -296,28 +298,47 @@ export async function sendSlackReply(
296
298
  }
297
299
 
298
300
  /**
299
- * Execute one Slack streaming operation against a channel, returning the
300
- * stream `ts` so the caller can carry it across `append`/`stop` calls. `start`
301
+ * Execute one growing-reply operation against a Slack channel, returning the
302
+ * stream `ts` so the caller can carry it across `append` and `stop`. `start`
301
303
  * mints a new `ts`; `append` and `stop` echo the one they were given.
302
304
  *
305
+ * Slack's stream *is* the reply, so `stop` finalizes the message already on
306
+ * screen. That is why the appended delta is what goes on the wire here while
307
+ * the op's complete `text` is used only to hoist any images out of the
308
+ * finished reply: Slack has been shown every word already.
309
+ *
303
310
  * Throwing on failure is intentional: the streaming session decides whether to
304
311
  * abandon the stream and let durable delivery post the full reply.
305
312
  */
306
313
  export async function sendSlackStreamOp(
307
314
  channel: string,
308
- op: SlackStreamOp,
315
+ op: StreamOp,
309
316
  ): Promise<SlackSendResult> {
317
+ const tasks = op.plan ? toSlackStreamTasks(op.plan.steps) : undefined;
318
+ const planTitle = op.plan?.title;
319
+
310
320
  switch (op.action) {
311
321
  case "start": {
322
+ if (!op.anchorMessageId) {
323
+ // Slack streams into a thread and rejects a start without one. Saying
324
+ // so is the honest answer: an empty thread id would be refused by the
325
+ // API anyway, and the caller falls back to sending the reply whole.
326
+ log.warn({ channel }, "Slack stream start has no thread to open on");
327
+ return { ok: false };
328
+ }
312
329
  const ts = await startSlackStream({
313
330
  channel,
314
- threadTs: op.threadTs,
315
- markdownText: op.markdownText,
316
- taskDisplayMode: op.taskDisplayMode,
317
- planTitle: op.planTitle,
318
- tasks: op.tasks,
319
- recipientUserId: op.recipientUserId,
320
- recipientTeamId: op.recipientTeamId,
331
+ threadTs: op.anchorMessageId,
332
+ markdownText: op.appended ?? op.text,
333
+ // Fixed for the stream's lifetime at start, while a plan usually
334
+ // arrives after the first text flush has opened it. It only affects
335
+ // how task chunks render, so a stream that never carries a plan still
336
+ // reads as a plain message.
337
+ taskDisplayMode: "plan",
338
+ planTitle,
339
+ tasks,
340
+ recipientUserId: op.audience?.userId,
341
+ recipientTeamId: op.audience?.userOrgId,
321
342
  });
322
343
  log.info({ channel, ts }, "Slack stream started");
323
344
  return { ok: ts !== undefined, ts };
@@ -325,28 +346,40 @@ export async function sendSlackStreamOp(
325
346
  case "append": {
326
347
  await appendSlackStream({
327
348
  channel,
328
- streamTs: op.streamTs,
329
- markdownText: op.markdownText,
330
- planTitle: op.planTitle,
331
- tasks: op.tasks,
349
+ streamTs: op.streamId,
350
+ markdownText: op.appended,
351
+ planTitle,
352
+ tasks,
332
353
  });
333
- return { ok: true, ts: op.streamTs };
354
+ return { ok: true, ts: op.streamId };
334
355
  }
335
356
  case "stop": {
336
357
  await stopSlackStream({
337
358
  channel,
338
- streamTs: op.streamTs,
339
- markdownText: op.markdownText,
340
- blocks: op.blocks,
341
- planTitle: op.planTitle,
342
- tasks: op.tasks,
359
+ streamTs: op.streamId,
360
+ markdownText: op.appended,
361
+ // Images referenced in the reply do not render inside the streamed
362
+ // markdown, so they are hoisted into blocks below it. Derived here
363
+ // from the whole reply rather than handed down, because which parts of
364
+ // a message become blocks is Slack's rendering decision.
365
+ blocks: op.text ? imageBlocksFor(op.text) : undefined,
366
+ planTitle,
367
+ tasks,
343
368
  });
344
- log.info({ channel, ts: op.streamTs }, "Slack stream stopped");
345
- return { ok: true, ts: op.streamTs };
369
+ log.info({ channel, ts: op.streamId }, "Slack stream stopped");
370
+ return { ok: true, ts: op.streamId };
346
371
  }
347
372
  }
348
373
  }
349
374
 
375
+ /** Image blocks for a finished reply, or nothing when it references none. */
376
+ function imageBlocksFor(text: string): KnownBlock[] | undefined {
377
+ const blocks = renderSlackBlocks(text)?.filter(
378
+ (block) => block.type === "image",
379
+ );
380
+ return blocks && blocks.length > 0 ? blocks : undefined;
381
+ }
382
+
350
383
  /**
351
384
  * Add or remove an emoji reaction on a Slack message.
352
385
  * Non-throwing: logs errors but returns silently.
@@ -376,37 +409,73 @@ export async function sendSlackReaction(
376
409
  }
377
410
  }
378
411
 
412
+ /** How Slack spells each activity phase on an agent session. */
413
+ const SLACK_SESSION_STATUS: Record<
414
+ AssistantActivityPhase,
415
+ "active" | "processing" | "suspended"
416
+ > = {
417
+ idle: "active",
418
+ thinking: "processing",
419
+ streaming: "processing",
420
+ tool_running: "processing",
421
+ // Slack's own word for a session waiting on a person, which is what an
422
+ // approval is. It suppresses the stop button, which would otherwise offer to
423
+ // cancel a turn that is not running.
424
+ awaiting_confirmation: "suspended",
425
+ };
426
+
379
427
  /**
380
- * Set or clear the Slack Assistants API thread status indicator.
381
- * Falls back to emoji reactions for installs without `assistant:write` scope.
428
+ * Set the status of the Slack agent session for a thread.
429
+ *
430
+ * `active` is a real transition rather than a clear: the loading UX stays up
431
+ * for an hour if a turn ends without one, so every caller that sets a busy
432
+ * phase owes an `idle`.
433
+ *
434
+ * `initiator_user_id` is read only when Slack creates the session, so it is
435
+ * passed on every call and Slack ignores it after the first.
436
+ *
437
+ * Falls back to an emoji reaction on failure, which is all a workspace that
438
+ * denies the scope can show. Reports whether the session status itself landed,
439
+ * because a caller that owes a terminal transition has to know it was lost: the
440
+ * reaction cannot clear a status the API already set.
382
441
  */
383
- export async function sendSlackAssistantThreadStatus(
384
- channel: string,
385
- threadTs: string,
386
- status: string,
387
- loadingMessages?: readonly string[],
388
- ): Promise<void> {
442
+ export async function sendSlackAgentSessionStatus(params: {
443
+ channel: string;
444
+ phase: AssistantActivityPhase;
445
+ /** Thread root, absent in a DM the app has not threaded. */
446
+ threadTs?: string;
447
+ /** The message that opened the turn, used only by the reaction fallback. */
448
+ messageTs?: string;
449
+ initiatorUserId?: string;
450
+ }): Promise<boolean> {
451
+ const { channel, phase, threadTs, messageTs, initiatorUserId } = params;
452
+ const status = SLACK_SESSION_STATUS[phase];
389
453
  try {
390
- const body: Record<string, unknown> = {
454
+ await callSlackApi("agents.sessions.setStatus", {
391
455
  channel_id: channel,
392
- thread_ts: threadTs,
393
456
  status,
394
- };
395
- if (loadingMessages !== undefined) {
396
- body.loading_messages = loadingMessages;
397
- }
398
-
399
- await callSlackApi("assistant.threads.setStatus", body);
400
- return;
457
+ ...(threadTs ? { thread_ts: threadTs } : {}),
458
+ ...(initiatorUserId ? { initiator_user_id: initiatorUserId } : {}),
459
+ });
460
+ return true;
401
461
  } catch {
402
462
  log.warn(
403
- { channel },
404
- "Slack assistant.threads.setStatus failed, falling back to reaction",
463
+ { channel, status },
464
+ "Slack agents.sessions.setStatus failed, falling back to reaction",
405
465
  );
406
466
  }
407
467
 
408
- const isSet = status.length > 0;
409
- await sendSlackReaction(channel, "eyes", threadTs, isSet ? "add" : "remove");
468
+ const reactionTarget = threadTs ?? messageTs;
469
+ if (!reactionTarget) {
470
+ return false;
471
+ }
472
+ await sendSlackReaction(
473
+ channel,
474
+ "eyes",
475
+ reactionTarget,
476
+ status === "processing" ? "add" : "remove",
477
+ );
478
+ return false;
410
479
  }
411
480
 
412
481
  export type SlackAttachmentResult = {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { toSlackStreamTasks } from "./stream-tasks.js";
4
+
5
+ describe("toSlackStreamTasks", () => {
6
+ test("maps steps onto Slack task cards with stable ids and details", () => {
7
+ expect(
8
+ toSlackStreamTasks([
9
+ {
10
+ label: "Check weather",
11
+ status: "completed",
12
+ detail: "Forecast fetched",
13
+ },
14
+ { label: "Summarize", status: "failed" },
15
+ ]),
16
+ ).toEqual([
17
+ {
18
+ id: "task-0",
19
+ title: "Check weather",
20
+ status: "complete",
21
+ details: "Forecast fetched",
22
+ },
23
+ { id: "task-1", title: "Summarize", status: "error" },
24
+ ]);
25
+ });
26
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Slack's task-card vocabulary for a plan carried on a growing reply.
3
+ *
4
+ * Lives channel-side because both halves are Slack's: the `task_update` chunk
5
+ * shape, and a status vocabulary that spells two of the assistant's four
6
+ * statuses differently. The core sends the plan; what it becomes is here.
7
+ */
8
+
9
+ import type { SlackStreamTask, StreamPlanStep } from "@vellumai/gateway-client";
10
+
11
+ const TASK_PROGRESS_STATUS_TO_SLACK: Record<
12
+ StreamPlanStep["status"],
13
+ SlackStreamTask["status"]
14
+ > = {
15
+ pending: "pending",
16
+ in_progress: "in_progress",
17
+ completed: "complete",
18
+ failed: "error",
19
+ };
20
+
21
+ /**
22
+ * Map ordered `task_progress` steps onto Slack streaming task cards. Step
23
+ * position supplies the stable card `id` (a step keeps its index across
24
+ * updates), the label becomes the card title, the step detail becomes the
25
+ * card details, and the surface status maps onto Slack's task-card status
26
+ * vocabulary.
27
+ *
28
+ * @see https://docs.slack.dev/ai/developing-agents
29
+ */
30
+ export function toSlackStreamTasks(
31
+ steps: readonly StreamPlanStep[],
32
+ ): SlackStreamTask[] {
33
+ return steps.map((step, index) => ({
34
+ id: `task-${index}`,
35
+ title: step.label,
36
+ status: TASK_PROGRESS_STATUS_TO_SLACK[step.status],
37
+ ...(step.detail ? { details: step.detail } : {}),
38
+ }));
39
+ }
@@ -1,11 +1,11 @@
1
+ import type { KnownBlock } from "@slack/types";
1
2
  import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
2
3
 
3
4
  import { getLogger } from "../../../util/logger.js";
4
5
  import type { ChannelTransport } from "../channel-transport.js";
5
6
  import {
6
- sendSlackAssistantThreadStatus,
7
+ sendSlackAgentSessionStatus,
7
8
  sendSlackAttachments,
8
- sendSlackReaction,
9
9
  sendSlackReply,
10
10
  sendSlackStreamOp,
11
11
  updateSlackMessage,
@@ -13,6 +13,11 @@ import {
13
13
 
14
14
  const log = getLogger("slack-transport");
15
15
 
16
+ /** Slack's rendering of a settled message. */
17
+ function mutedBlocks(text: string): KnownBlock[] {
18
+ return [{ type: "context", elements: [{ type: "mrkdwn", text }] }];
19
+ }
20
+
16
21
  export const slackTransport: ChannelTransport = {
17
22
  channel: "slack",
18
23
 
@@ -25,7 +30,7 @@ export const slackTransport: ChannelTransport = {
25
30
  const result = await sendSlackReply(chatId, text, {
26
31
  threadTs,
27
32
  approval: payload.approval,
28
- useBlocks: payload.useBlocks,
33
+ useBlocks: payload.renderRichly,
29
34
  audience: payload.audience,
30
35
  });
31
36
  sentTs = result.ts;
@@ -57,29 +62,26 @@ export const slackTransport: ChannelTransport = {
57
62
  target.chatId,
58
63
  target.messageId,
59
64
  target.text,
60
- { blocks: target.blocks, useBlocks: target.useBlocks },
65
+ {
66
+ // Slack's answer to a settled message is a context block, which reads
67
+ // smaller and greyer than body text.
68
+ blocks:
69
+ target.emphasis === "muted" ? mutedBlocks(target.text) : undefined,
70
+ useBlocks: target.renderRichly,
71
+ },
61
72
  );
62
73
  return { ok: true, ts: result.ts };
63
74
  },
64
75
 
65
- async react(_ctx, target) {
66
- await sendSlackReaction(
67
- target.chatId,
68
- target.emoji,
69
- target.messageId,
70
- target.action,
71
- );
72
- return { ok: true };
73
- },
74
-
75
- async setThreadStatus(_ctx, status) {
76
- await sendSlackAssistantThreadStatus(
77
- status.chatId,
78
- status.threadTs,
79
- status.status,
80
- status.loadingMessages,
81
- );
82
- return { ok: true };
76
+ async setActivity(ctx, target) {
77
+ const ok = await sendSlackAgentSessionStatus({
78
+ channel: target.chatId,
79
+ phase: target.phase,
80
+ threadTs: ctx.params.threadTs,
81
+ messageTs: ctx.params.messageTs,
82
+ initiatorUserId: target.initiatorUserId,
83
+ });
84
+ return { ok };
83
85
  },
84
86
 
85
87
  async streamReply(_ctx, chatId, op) {