@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
@@ -7,13 +7,11 @@
7
7
  * Extracted from inbound-message-handler.ts to keep the top-level handler
8
8
  * focused on orchestration.
9
9
  */
10
- import type { AssistantEvent } from "../../../api/index.js";
10
+ import type {
11
+ AssistantActivityPhase,
12
+ AssistantEvent,
13
+ } from "../../../api/index.js";
11
14
  import { resolveGuardianPromptDelivery } from "../../../approvals/guardian-channel-delivery.js";
12
- import {
13
- extractMessageTsFromCallbackUrl,
14
- extractThreadTsFromCallbackUrl,
15
- isSlackDeliveryCallbackUrl,
16
- } from "../../../channels/slack-callback-url.js";
17
15
  import type { ChannelId, InterfaceId } from "../../../channels/types.js";
18
16
  import {
19
17
  getGuardianDelivery,
@@ -22,10 +20,9 @@ import {
22
20
  import { isConversationBusyError } from "../../../daemon/conversation-messaging.js";
23
21
  import type { TrustContext } from "../../../daemon/trust-context-types.js";
24
22
  import {
25
- sendChannelReaction,
26
- sendChannelTyping,
27
- setChannelThreadStatus,
28
- supportsChannelTyping,
23
+ channelActivityRefreshMs,
24
+ setChannelActivity,
25
+ supportsChannelActivity,
29
26
  } from "../../../messaging/providers/index.js";
30
27
  import {
31
28
  getSiblingStreamedReplyTs,
@@ -56,11 +53,6 @@ import type {
56
53
  } from "../../http-types.js";
57
54
  import { hasDeliverableAssistantText } from "../../no-response.js";
58
55
  import { createSlackReplySession } from "../../slack-reply-session.js";
59
- import type { TaskProgressData } from "../../slack-task-progress.js";
60
- import {
61
- getTaskProgressDataFromSurfaceData,
62
- mergeTaskProgressData,
63
- } from "../../slack-task-progress.js";
64
56
  import { isContactTrustClass } from "../../trust-class.js";
65
57
  import { resolveRoutingState } from "../../trust-context-resolver.js";
66
58
  import { finalizeEventDelivery } from "../channel-delivery-routes.js";
@@ -168,19 +160,14 @@ export function processChannelMessageInBackground(
168
160
  // `channel-turn-admission.ts` for why channel turns defer rather than route
169
161
  // through the SSE-oriented conversation queue.
170
162
  void withChannelTurnAdmission(conversationId, async () => {
171
- const stopTypingHeartbeat =
172
- replyCallbackUrl && supportsChannelTyping(replyCallbackUrl)
173
- ? startTypingHeartbeat(replyCallbackUrl, externalChatId)
174
- : undefined;
175
-
176
- const slackThinkingStatus = createSlackThinkingStatusController({
177
- sourceChannel,
163
+ const channelActivity = startChannelActivity({
178
164
  replyCallbackUrl,
165
+ conversationId,
179
166
  chatId: externalChatId,
180
- startImmediately: shouldStartSlackThinkingStatusImmediately({
181
- sourceChannel,
167
+ initiatorUserId: slackInbound?.actorExternalUserId,
168
+ startImmediately: shouldShowActivityImmediately({
182
169
  chatType,
183
- slackBotMentioned,
170
+ botMentioned: slackBotMentioned,
184
171
  }),
185
172
  });
186
173
  const stopApprovalWatcher = replyCallbackUrl
@@ -244,7 +231,7 @@ export function processChannelMessageInBackground(
244
231
  replyMessageId = msg.messageId;
245
232
  }
246
233
  slackReplySession?.observeEvent(msg);
247
- slackThinkingStatus?.observeEvent(msg);
234
+ channelActivity?.observeEvent(msg);
248
235
  };
249
236
 
250
237
  let userMessageId: string | undefined;
@@ -366,8 +353,7 @@ export function processChannelMessageInBackground(
366
353
  }
367
354
  }
368
355
  } finally {
369
- stopTypingHeartbeat?.();
370
- slackThinkingStatus?.stop();
356
+ channelActivity?.stop();
371
357
  stopApprovalWatcher?.();
372
358
  stopTcApprovalNotifier?.();
373
359
  }
@@ -380,361 +366,198 @@ export function processChannelMessageInBackground(
380
366
  }
381
367
 
382
368
  // ---------------------------------------------------------------------------
383
- // Telegram typing heartbeat
369
+ // Channel activity indicator
384
370
  // ---------------------------------------------------------------------------
385
371
 
386
372
  /**
387
- * How often the working indicator is re-sent while a turn runs.
388
- *
389
- * Every channel that has one expires it after a few seconds and offers no
390
- * start/stop pair to hold it open, so showing it across a multi-second turn
391
- * means re-sending on a timer. The interval has to stay under the shortest
392
- * expiry of any channel that implements `typing`: Telegram is about five
393
- * seconds, Discord about ten. Anyone changing it should check the current
394
- * windows first.
395
- *
396
- * No explicit stop is needed: both clear the indicator when the bot posts.
397
- *
398
- * https://core.telegram.org/bots/api#sendchataction
399
- * https://discord.com/developers/docs/resources/channel#trigger-typing-indicator
373
+ * How often the phase is recomputed for a channel whose indicator holds until
374
+ * it is changed. Such a channel is only called when the phase actually moves,
375
+ * so this costs a comparison rather than a request.
400
376
  */
401
- const TYPING_INTERVAL_MS = 4_000;
402
-
403
- function startTypingHeartbeat(callbackUrl: string, chatId: string): () => void {
404
- let active = true;
405
- let inFlight = false;
406
-
407
- const emitTyping = (): void => {
408
- if (!active || inFlight) {
409
- return;
410
- }
411
- inFlight = true;
412
- void sendChannelTyping(callbackUrl, chatId)
413
- .catch((err) => {
414
- log.debug(
415
- { err, chatId },
416
- "Failed to deliver Telegram typing indicator",
417
- );
418
- })
419
- .finally(() => {
420
- inFlight = false;
421
- });
422
- };
423
-
424
- emitTyping();
425
-
426
- const interval = setInterval(emitTyping, TYPING_INTERVAL_MS);
427
- (interval as { unref?: () => void }).unref?.();
428
-
429
- return () => {
430
- active = false;
431
- clearInterval(interval);
432
- };
433
- }
377
+ const ACTIVITY_PHASE_POLL_MS = 1_000;
434
378
 
435
- // ---------------------------------------------------------------------------
436
- // Slack Assistants API thinking status indicator
437
- // ---------------------------------------------------------------------------
438
-
439
- type SlackThinkingStatusController = {
379
+ type ChannelActivityController = {
440
380
  observeEvent: (msg: AssistantEvent) => void;
441
381
  stop: () => void;
442
382
  };
443
383
 
444
- type SlackThinkingStatusHandle = {
445
- updateLoadingMessages: (loadingMessages?: string[]) => void;
446
- clear: () => void;
447
- };
448
-
449
- export function shouldStartSlackThinkingStatusForText(text: string): boolean {
384
+ export function shouldShowActivityForText(text: string): boolean {
450
385
  return hasDeliverableAssistantText(text);
451
386
  }
452
387
 
453
- function shouldEmitSlackThinkingStatus(
454
- sourceChannel: ChannelId,
455
- replyCallbackUrl?: string,
456
- ): boolean {
457
- return (
458
- sourceChannel === "slack" && isSlackDeliveryCallbackUrl(replyCallbackUrl)
459
- );
460
- }
388
+ /**
389
+ * Room shapes with one other participant, in each channel's own word for it.
390
+ *
391
+ * SHIM. `chatType` reaches the daemon as whatever the channel called it, so one
392
+ * idea arrives under three spellings and a fourth channel would add a fourth.
393
+ * It belongs normalized at ingress, where each channel already parses its own
394
+ * payload and knows the answer. Until then this is the single place that has to
395
+ * widen for a new channel, and the single place to delete once it does not.
396
+ *
397
+ * Slack's `mpim` is deliberately absent: a group DM has other readers, so it is
398
+ * a room rather than a direct conversation.
399
+ */
400
+ const DIRECT_CHAT_TYPES: ReadonlySet<string> = new Set([
401
+ "im", // Slack
402
+ "dm", // Discord
403
+ "private", // Telegram
404
+ ]);
461
405
 
462
- export function shouldStartSlackThinkingStatusImmediately(params: {
463
- sourceChannel: ChannelId;
406
+ /**
407
+ * Whether the assistant should show it is working before it has produced any
408
+ * text: it was addressed directly, so a reply is expected either way.
409
+ */
410
+ export function shouldShowActivityImmediately(params: {
464
411
  chatType?: string;
465
- slackBotMentioned?: boolean;
412
+ botMentioned?: boolean;
466
413
  }): boolean {
467
- if (params.sourceChannel !== "slack") {
468
- return false;
469
- }
470
- return params.chatType === "im" || params.slackBotMentioned === true;
414
+ return (
415
+ (params.chatType !== undefined && DIRECT_CHAT_TYPES.has(params.chatType)) ||
416
+ params.botMentioned === true
417
+ );
471
418
  }
472
419
 
473
- function createSlackThinkingStatusController(params: {
474
- sourceChannel: ChannelId;
420
+ /**
421
+ * Drive one conversation's activity indicator for the length of a turn.
422
+ *
423
+ * The indicator is held back until the turn is known to be producing a reply,
424
+ * because a channel where the assistant was not addressed may process a
425
+ * message and decide to stay quiet, and an indicator there promises something
426
+ * that is not coming. Being addressed directly is enough on its own.
427
+ *
428
+ * TRANSITIONAL: the phase is derived here from what this dispatch knows, and
429
+ * the daemon already publishes the same lifecycle as `assistant_activity_state`
430
+ * with a monotonic version. That event reaches conversation observers rather
431
+ * than this turn's `onEvent`, so consuming it is its own change; until then
432
+ * this is a second derivation of one lifecycle and should not be extended.
433
+ */
434
+ function startChannelActivity(params: {
475
435
  replyCallbackUrl?: string;
436
+ conversationId: string;
476
437
  chatId: string;
477
- startImmediately?: boolean;
478
- }): SlackThinkingStatusController | undefined {
479
- const { sourceChannel, replyCallbackUrl, chatId, startImmediately } = params;
480
- if (
481
- !replyCallbackUrl ||
482
- !shouldEmitSlackThinkingStatus(sourceChannel, replyCallbackUrl)
483
- ) {
438
+ initiatorUserId?: string;
439
+ startImmediately: boolean;
440
+ }): ChannelActivityController | undefined {
441
+ const { replyCallbackUrl, conversationId, chatId, initiatorUserId } = params;
442
+ if (!replyCallbackUrl || !supportsChannelActivity(replyCallbackUrl)) {
484
443
  return undefined;
485
444
  }
486
- const callbackUrl = replyCallbackUrl;
445
+ const url = replyCallbackUrl;
487
446
 
447
+ const refreshMs = channelActivityRefreshMs(url);
488
448
  let stopped = false;
489
- let slackThinkingStatus: SlackThinkingStatusHandle | undefined;
449
+ let showing = false;
490
450
  let observedAssistantText = "";
491
- let currentLoadingMessages: string[] | undefined = startImmediately
492
- ? [...SLACK_GENERIC_LOADING_MESSAGES]
493
- : undefined;
494
- let lastSentLoadingMessageKey: string | undefined;
495
- const taskProgressBySurfaceId = new Map<string, TaskProgressData>();
496
-
497
- const start = (): void => {
498
- if (stopped || slackThinkingStatus) {
451
+ let lastPhase: AssistantActivityPhase | undefined;
452
+
453
+ // Serialized so a later phase cannot overtake an earlier one and leave the
454
+ // channel showing a state the turn has already left.
455
+ let pending: Promise<unknown> = Promise.resolve();
456
+ let outstanding = 0;
457
+
458
+ const deliver = (phase: AssistantActivityPhase): Promise<boolean> =>
459
+ setChannelActivity(url, {
460
+ chatId,
461
+ phase,
462
+ ...(initiatorUserId ? { initiatorUserId } : {}),
463
+ })
464
+ .then((result) => result.ok)
465
+ .catch((err) => {
466
+ log.debug({ err, chatId, phase }, "Failed to set channel activity");
467
+ return false;
468
+ });
469
+
470
+ /**
471
+ * `refresh` is a re-assertion of a phase the channel is already showing, so
472
+ * it is dropped whenever anything is still outstanding. A slow channel would
473
+ * otherwise queue one per tick, and a busy phase that runs after the turn's
474
+ * `idle` raises the indicator again on a turn that has finished.
475
+ *
476
+ * `outstanding` counts sends that are queued or running, not just the one in
477
+ * flight. A flag cannot express two at once: the earlier link's completion
478
+ * would clear it while its successor is still waiting its turn on the chain,
479
+ * and the next tick would queue a third.
480
+ */
481
+ const send = (
482
+ phase: AssistantActivityPhase,
483
+ options?: { refresh?: boolean; retryOnce?: boolean },
484
+ ): void => {
485
+ if (options?.refresh && outstanding > 0) {
499
486
  return;
500
487
  }
501
- slackThinkingStatus = setSlackThinkingStatus(
502
- callbackUrl,
503
- chatId,
504
- currentLoadingMessages,
505
- );
506
- lastSentLoadingMessageKey = getLoadingMessagesKey(currentLoadingMessages);
488
+ lastPhase = phase;
489
+ outstanding += 1;
490
+ pending = pending
491
+ .then(async () => {
492
+ const ok = await deliver(phase);
493
+ // A lost terminal transition is not cosmetic: a channel that holds its
494
+ // indicator keeps showing the assistant as working until something
495
+ // else changes it, which on Slack is an hour away.
496
+ if (!ok && options?.retryOnce) {
497
+ await deliver(phase);
498
+ }
499
+ })
500
+ .finally(() => {
501
+ outstanding -= 1;
502
+ });
507
503
  };
508
504
 
509
- const maybeUpdateLoadingMessages = (): void => {
510
- const nextLoadingMessageKey = getLoadingMessagesKey(currentLoadingMessages);
511
- if (nextLoadingMessageKey === lastSentLoadingMessageKey) {
505
+ const currentPhase = (): AssistantActivityPhase =>
506
+ getApprovalInfoByConversation(conversationId).length > 0
507
+ ? "awaiting_confirmation"
508
+ : "thinking";
509
+
510
+ const tick = (): void => {
511
+ if (stopped || !showing) {
512
512
  return;
513
513
  }
514
- lastSentLoadingMessageKey = nextLoadingMessageKey;
515
- slackThinkingStatus?.updateLoadingMessages(currentLoadingMessages);
514
+ const phase = currentPhase();
515
+ // A channel whose indicator expires needs the same phase re-asserted; one
516
+ // that holds only needs to hear about a change.
517
+ if (phase !== lastPhase) {
518
+ send(phase);
519
+ } else if (refreshMs !== undefined) {
520
+ send(phase, { refresh: true });
521
+ }
516
522
  };
517
523
 
518
- const observeTaskProgress = (msg: AssistantEvent): void => {
519
- if (msg.type === "ui_surface_show") {
520
- const progress = getTaskProgressDataFromSurfaceData(msg.data);
521
- if (!progress) {
522
- return;
523
- }
524
- taskProgressBySurfaceId.set(msg.surfaceId, progress);
525
- } else if (msg.type === "ui_surface_update") {
526
- const existing = taskProgressBySurfaceId.get(msg.surfaceId);
527
- const progress = mergeTaskProgressData(existing, msg.data);
528
- if (!progress) {
529
- return;
530
- }
531
- taskProgressBySurfaceId.set(msg.surfaceId, progress);
532
- } else {
524
+ const interval = setInterval(tick, refreshMs ?? ACTIVITY_PHASE_POLL_MS);
525
+ (interval as { unref?: () => void }).unref?.();
526
+
527
+ const show = (): void => {
528
+ if (stopped || showing) {
533
529
  return;
534
530
  }
535
-
536
- currentLoadingMessages =
537
- getTaskProgressLoadingMessage(
538
- taskProgressBySurfaceId.get(msg.surfaceId),
539
- ) ?? [];
540
- maybeUpdateLoadingMessages();
531
+ showing = true;
532
+ send(currentPhase());
541
533
  };
542
534
 
543
- if (startImmediately) {
544
- start();
535
+ if (params.startImmediately) {
536
+ show();
545
537
  }
546
538
 
547
539
  return {
548
540
  observeEvent(msg) {
549
- if (stopped) {
550
- return;
551
- }
552
-
553
- if (msg.type === "ui_surface_show" || msg.type === "ui_surface_update") {
554
- observeTaskProgress(msg);
541
+ if (stopped || showing || msg.type !== "assistant_text_delta") {
555
542
  return;
556
543
  }
557
-
558
- if (slackThinkingStatus || msg.type !== "assistant_text_delta") {
559
- return;
560
- }
561
-
562
544
  observedAssistantText += msg.text;
563
- if (shouldStartSlackThinkingStatusForText(observedAssistantText)) {
564
- start();
545
+ if (shouldShowActivityForText(observedAssistantText)) {
546
+ show();
565
547
  }
566
548
  },
567
549
  stop() {
568
- stopped = true;
569
- slackThinkingStatus?.clear();
570
- },
571
- };
572
- }
573
-
574
- const SLACK_THINKING_MAX_DURATION_MS = 120_000;
575
- const SLACK_GENERIC_LOADING_MESSAGES = ["Thinking…"] as const;
576
- const SLACK_THINKING_STATUSES = ["is on it", "is working hard"] as const;
577
-
578
- function getRandomSlackThinkingStatus(): string {
579
- return SLACK_THINKING_STATUSES[
580
- Math.floor(Math.random() * SLACK_THINKING_STATUSES.length)
581
- ]!;
582
- }
583
-
584
- function getLoadingMessagesKey(loadingMessages?: string[]): string | undefined {
585
- return loadingMessages?.join("\n");
586
- }
587
-
588
- function getTaskProgressLoadingMessage(
589
- progress: TaskProgressData | undefined,
590
- ): string[] | undefined {
591
- if (!progress) {
592
- return undefined;
593
- }
594
-
595
- const activeStepIndex = progress.steps.findIndex(
596
- (step) => step.status === "in_progress",
597
- );
598
- if (activeStepIndex < 0) {
599
- return undefined;
600
- }
601
-
602
- const activeStep = progress.steps[activeStepIndex]!;
603
- return [
604
- `In progress (${activeStepIndex + 1}/${progress.steps.length}): ${
605
- activeStep.label
606
- }`,
607
- ];
608
- }
609
-
610
- /**
611
- * Set Slack Assistants API status on the thread and return a handle for
612
- * updating loading messages or clearing the indicator.
613
- *
614
- * A safety timer auto-clears the status after {@link SLACK_THINKING_MAX_DURATION_MS}
615
- * to prevent a stuck indicator when `processMessage` hangs.
616
- */
617
- function setSlackThinkingStatus(
618
- callbackUrl: string,
619
- chatId: string,
620
- loadingMessages?: string[],
621
- ): SlackThinkingStatusHandle {
622
- let cleared = false;
623
-
624
- // Extract the thread timestamp from the callback URL so we can target
625
- // the correct thread for the Assistants API status.
626
- const threadTs = extractThreadTsFromCallbackUrl(callbackUrl);
627
-
628
- // For non-threaded DMs, fall back to emoji reaction on the original message.
629
- if (!threadTs) {
630
- const messageTs = extractMessageTsFromCallbackUrl(callbackUrl);
631
- if (!messageTs) {
632
- return {
633
- updateLoadingMessages: () => {},
634
- clear: () => {},
635
- };
636
- }
637
-
638
- const addPromise = sendChannelReaction(callbackUrl, {
639
- chatId,
640
- messageId: messageTs,
641
- emoji: "eyes",
642
- action: "add",
643
- }).catch((err) => {
644
- log.debug({ err, chatId, messageTs }, "Failed to add eyes reaction");
645
- });
646
-
647
- const clearReaction = (): void => {
648
- if (cleared) {
550
+ if (stopped) {
649
551
  return;
650
552
  }
651
- cleared = true;
652
- clearTimeout(safetyTimer);
653
- void addPromise.then(() =>
654
- sendChannelReaction(callbackUrl, {
655
- chatId,
656
- messageId: messageTs,
657
- emoji: "eyes",
658
- action: "remove",
659
- }).catch((err) => {
660
- log.debug(
661
- { err, chatId, messageTs },
662
- "Failed to remove eyes reaction",
663
- );
664
- }),
665
- );
666
- };
667
-
668
- const safetyTimer = setTimeout(
669
- clearReaction,
670
- SLACK_THINKING_MAX_DURATION_MS,
671
- );
672
- (safetyTimer as { unref?: () => void }).unref?.();
673
-
674
- return {
675
- updateLoadingMessages: () => {},
676
- clear: clearReaction,
677
- };
678
- }
679
-
680
- // Track the set promise so clear waits for it to settle first,
681
- // preventing a race where clear arrives at Slack before set.
682
- let statusPromise = setChannelThreadStatus(callbackUrl, {
683
- chatId,
684
- threadTs,
685
- status: getRandomSlackThinkingStatus(),
686
- ...(loadingMessages ? { loadingMessages } : {}),
687
- }).catch((err) => {
688
- log.debug({ err, chatId, threadTs }, "Failed to set Slack thinking status");
689
- });
690
-
691
- const updateLoadingMessages = (nextLoadingMessages?: string[]): void => {
692
- if (cleared) {
693
- return;
694
- }
695
- statusPromise = statusPromise.then(() =>
696
- setChannelThreadStatus(callbackUrl, {
697
- chatId,
698
- threadTs,
699
- status: getRandomSlackThinkingStatus(),
700
- ...(nextLoadingMessages
701
- ? { loadingMessages: nextLoadingMessages }
702
- : {}),
703
- }).catch((err) => {
704
- log.debug(
705
- { err, chatId, threadTs },
706
- "Failed to update Slack thinking status",
707
- );
708
- }),
709
- );
710
- };
711
-
712
- const clearStatus = (): void => {
713
- if (cleared) {
714
- return;
715
- }
716
- cleared = true;
717
- clearTimeout(safetyTimer);
718
- void statusPromise.then(() =>
719
- setChannelThreadStatus(callbackUrl, {
720
- chatId,
721
- threadTs,
722
- status: "",
723
- }).catch((err) => {
724
- log.debug(
725
- { err, chatId, threadTs },
726
- "Failed to clear Slack thinking status",
727
- );
728
- }),
729
- );
730
- };
731
-
732
- const safetyTimer = setTimeout(clearStatus, SLACK_THINKING_MAX_DURATION_MS);
733
- (safetyTimer as { unref?: () => void }).unref?.();
734
-
735
- return {
736
- updateLoadingMessages,
737
- clear: clearStatus,
553
+ stopped = true;
554
+ clearInterval(interval);
555
+ // Only owed when something was shown. `idle` is a real transition on
556
+ // channels that hold the indicator, not a no-op clear.
557
+ if (showing) {
558
+ send("idle", { retryOnce: true });
559
+ }
560
+ },
738
561
  };
739
562
  }
740
563
 
@@ -91,6 +91,7 @@ import { ROUTES as INFERENCE_PROFILE_SESSION_ROUTES } from "./inference-profile-
91
91
  import { ROUTES as INFERENCE_PROFILES_ROUTES } from "./inference-profiles-routes.js";
92
92
  import { ROUTES as INFERENCE_PROVIDER_CONNECTION_ROUTES } from "./inference-provider-connection-routes.js";
93
93
  import { ROUTES as INFERENCE_SEND_ROUTES } from "./inference-send-routes.js";
94
+ import { ROUTES as INGRESS_STATUS_ROUTES } from "./ingress-status-routes.js";
94
95
  import { ROUTES as A2A_ROUTES } from "./integrations/a2a.js";
95
96
  import { ROUTES as SLACK_CHANNEL_CONFIG_ROUTES } from "./integrations/slack/channel.js";
96
97
  import { ROUTES as SLACK_CHANNELS_ROUTES } from "./integrations/slack/channels.js";
@@ -240,6 +241,7 @@ export const ROUTES: RouteDefinition[] = [
240
241
  ...INFERENCE_PROFILES_ROUTES,
241
242
  ...INFERENCE_PROVIDER_CONNECTION_ROUTES,
242
243
  ...INFERENCE_SEND_ROUTES,
244
+ ...INGRESS_STATUS_ROUTES,
243
245
  ...INTERNAL_OAUTH_ROUTES,
244
246
  ...INTERNAL_TELEMETRY_ROUTES,
245
247
  ...MCP_AUTH_ROUTES,