@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
@@ -88,18 +88,29 @@ mock.module("../../gateway-client.js", () => ({
88
88
  },
89
89
  }));
90
90
 
91
- const sentReactions: Array<{
92
- callbackUrl: string;
93
- target: Record<string, unknown>;
94
- }> = [];
95
91
  const sentStreamOps: Array<Record<string, unknown>> = [];
96
92
  let sendChannelStreamOpImpl: (
97
93
  op: Record<string, unknown>,
98
94
  ) => Promise<{ ok: boolean; ts?: string }> = async () => ({ ok: true });
99
- const sentThreadStatuses: Array<Record<string, unknown>> = [];
95
+ const sentActivity: Array<Record<string, unknown>> = [];
96
+ let setChannelActivityImpl: (
97
+ target: Record<string, unknown>,
98
+ ) => { ok: boolean } | Promise<{ ok: boolean }> = () => ({ ok: true });
99
+ // Undefined stands for a channel whose indicator holds until it is changed, so
100
+ // the controller reports each phase once and the assertions read as the turn's
101
+ // lifecycle rather than as timer ticks. A test that needs the self-expiring
102
+ // shape sets a cadence.
103
+ let activityRefreshMsImpl: () => number | undefined = () => undefined;
100
104
  mock.module("../../../messaging/providers/index.js", () => ({
101
- sendChannelTyping: async () => ({ ok: true }),
102
- supportsChannelTyping: () => false,
105
+ supportsChannelActivity: () => true,
106
+ channelActivityRefreshMs: () => activityRefreshMsImpl(),
107
+ setChannelActivity: async (
108
+ _callbackUrl: string,
109
+ target: Record<string, unknown>,
110
+ ) => {
111
+ sentActivity.push(target);
112
+ return setChannelActivityImpl(target);
113
+ },
103
114
  sendChannelStreamOp: async (
104
115
  _callbackUrl: string,
105
116
  _chatId: string,
@@ -108,20 +119,6 @@ mock.module("../../../messaging/providers/index.js", () => ({
108
119
  sentStreamOps.push(op);
109
120
  return sendChannelStreamOpImpl(op);
110
121
  },
111
- setChannelThreadStatus: async (
112
- _callbackUrl: string,
113
- status: Record<string, unknown>,
114
- ) => {
115
- sentThreadStatuses.push(status);
116
- return { ok: true };
117
- },
118
- sendChannelReaction: async (
119
- callbackUrl: string,
120
- target: Record<string, unknown>,
121
- ) => {
122
- sentReactions.push({ callbackUrl, target });
123
- return { ok: true };
124
- },
125
122
  }));
126
123
 
127
124
  mock.module("../../channel-reply-delivery.js", () => ({
@@ -154,8 +151,8 @@ import type { MessageProcessor } from "../../http-types.js";
154
151
  import {
155
152
  isBoundGuardianActor,
156
153
  processChannelMessageInBackground,
157
- shouldStartSlackThinkingStatusForText,
158
- shouldStartSlackThinkingStatusImmediately,
154
+ shouldShowActivityForText,
155
+ shouldShowActivityImmediately,
159
156
  } from "./background-dispatch.js";
160
157
  import { __resetChannelTurnAdmissionForTests } from "./channel-turn-admission.js";
161
158
 
@@ -163,10 +160,11 @@ beforeEach(() => {
163
160
  __resetChannelTurnAdmissionForTests();
164
161
  clearConversations();
165
162
  deliveredChannelReplies.length = 0;
166
- sentReactions.length = 0;
167
163
  sentStreamOps.length = 0;
168
164
  sendChannelStreamOpImpl = async () => ({ ok: true });
169
- sentThreadStatuses.length = 0;
165
+ sentActivity.length = 0;
166
+ setChannelActivityImpl = () => ({ ok: true });
167
+ activityRefreshMsImpl = () => undefined;
170
168
  markedProcessedEvents.length = 0;
171
169
  processingFailureEvents.length = 0;
172
170
  retryableFailureEvents.length = 0;
@@ -568,11 +566,11 @@ describe("processChannelMessageInBackground — reply delivery", () => {
568
566
  expect(slackStreamOps()).toEqual([
569
567
  {
570
568
  action: "start",
571
- threadTs,
572
- markdownText: "Streamed DM reply.",
573
- taskDisplayMode: "plan",
569
+ anchorMessageId: threadTs,
570
+ text: "Streamed DM reply.",
571
+ appended: "Streamed DM reply.",
574
572
  },
575
- { action: "stop", streamTs },
573
+ { action: "stop", streamId: streamTs, text: "Streamed DM reply." },
576
574
  ]);
577
575
  expect(
578
576
  deliveredChannelReplies
@@ -893,13 +891,7 @@ describe("processChannelMessageInBackground — admission (queue if busy)", () =
893
891
  });
894
892
  });
895
893
 
896
- describe("Slack thinking status timing", () => {
897
- const slackStatusLabels = [
898
- "is on it",
899
- "is working hard",
900
- "is touching grass",
901
- ];
902
-
894
+ describe("channel activity timing", () => {
903
895
  const trustCtx: TrustContext = {
904
896
  trustClass: "guardian",
905
897
  guardianExternalUserId: "guardian-1",
@@ -909,73 +901,74 @@ describe("Slack thinking status timing", () => {
909
901
  const flush = (): Promise<void> =>
910
902
  new Promise((resolve) => setTimeout(resolve, 10));
911
903
 
904
+ const phases = (): unknown[] =>
905
+ sentActivity.map((entry) => (entry as { phase?: unknown }).phase);
906
+
912
907
  beforeEach(() => {
913
908
  deliveredChannelReplies.length = 0;
914
909
  });
915
910
 
916
- test("recognizes only deliverable text as a Slack thinking-status trigger", () => {
917
- expect(shouldStartSlackThinkingStatusForText("")).toBe(false);
918
- expect(shouldStartSlackThinkingStatusForText(" ")).toBe(false);
919
- expect(shouldStartSlackThinkingStatusForText("<")).toBe(false);
920
- expect(shouldStartSlackThinkingStatusForText("<no_response")).toBe(false);
921
- expect(shouldStartSlackThinkingStatusForText("<no_response/>")).toBe(false);
922
- expect(shouldStartSlackThinkingStatusForText(" <no_response /> ")).toBe(
911
+ test("recognizes only deliverable text as a reason to show activity", () => {
912
+ expect(shouldShowActivityForText("")).toBe(false);
913
+ expect(shouldShowActivityForText(" ")).toBe(false);
914
+ expect(shouldShowActivityForText("<")).toBe(false);
915
+ expect(shouldShowActivityForText("<no_response")).toBe(false);
916
+ expect(shouldShowActivityForText("<no_response/>")).toBe(false);
917
+ expect(shouldShowActivityForText(" <no_response /> ")).toBe(false);
918
+ expect(shouldShowActivityForText("Real response.")).toBe(true);
919
+ expect(shouldShowActivityForText("<no_response/>\nReal response.")).toBe(
920
+ true,
921
+ );
922
+ });
923
+
924
+ test("recognizes a direct conversation in every channel's word for one", () => {
925
+ // One idea, three spellings, because `chatType` arrives as whatever the
926
+ // channel called it. Slack `im`, Discord `dm`, Telegram `private`. Missing
927
+ // one does not fail loudly: that channel silently reads as an ambient room
928
+ // and stops showing an indicator it used to show.
929
+ expect(shouldShowActivityImmediately({ chatType: "im" })).toBe(true);
930
+ expect(shouldShowActivityImmediately({ chatType: "dm" })).toBe(true);
931
+ expect(shouldShowActivityImmediately({ chatType: "private" })).toBe(true);
932
+ });
933
+
934
+ test("treats a room with other readers as ambient, including a group DM", () => {
935
+ // Slack's `mpim` has other readers, so it is a room: the assistant may
936
+ // process the traffic and decide to stay quiet, and an indicator there
937
+ // announces it was reading.
938
+ expect(shouldShowActivityImmediately({ chatType: "mpim" })).toBe(false);
939
+ expect(shouldShowActivityImmediately({ chatType: "channel" })).toBe(false);
940
+ expect(shouldShowActivityImmediately({ chatType: "supergroup" })).toBe(
923
941
  false,
924
942
  );
925
- expect(shouldStartSlackThinkingStatusForText("Real response.")).toBe(true);
926
- expect(
927
- shouldStartSlackThinkingStatusForText("<no_response/>\nReal response."),
928
- ).toBe(true);
943
+ expect(shouldShowActivityImmediately({})).toBe(false);
929
944
  });
930
945
 
931
- test("starts Slack thinking status immediately for DMs and direct mentions", () => {
932
- expect(
933
- shouldStartSlackThinkingStatusImmediately({
934
- sourceChannel: "slack",
935
- chatType: "im",
936
- }),
937
- ).toBe(true);
938
- expect(
939
- shouldStartSlackThinkingStatusImmediately({
940
- sourceChannel: "slack",
941
- slackBotMentioned: true,
942
- }),
943
- ).toBe(true);
946
+ test("shows activity immediately when the assistant is mentioned", () => {
947
+ expect(shouldShowActivityImmediately({ botMentioned: true })).toBe(true);
944
948
  expect(
945
- shouldStartSlackThinkingStatusImmediately({
946
- sourceChannel: "slack",
949
+ shouldShowActivityImmediately({
947
950
  chatType: "channel",
951
+ botMentioned: true,
948
952
  }),
949
- ).toBe(false);
950
- expect(
951
- shouldStartSlackThinkingStatusImmediately({
952
- sourceChannel: "telegram",
953
- chatType: "im",
954
- slackBotMentioned: true,
955
- }),
956
- ).toBe(false);
953
+ ).toBe(true);
957
954
  });
958
955
 
959
- test("sets Slack thinking indicator immediately for a DM", async () => {
960
- const conversationId = "conv-dm-immediate-status";
956
+ test("shows activity immediately in a DM and settles it when the turn ends", async () => {
957
+ const conversationId = "conv-dm-immediate-activity";
961
958
  const channelId = "D-DM-IMMEDIATE";
962
959
  const messageTs = "1700000000.000010";
963
960
 
964
961
  const processMessage: MessageProcessor = async () => {
965
- expect(sentReactions).toHaveLength(1);
966
- expect(sentReactions[0]!.target).toEqual({
967
- chatId: channelId,
968
- messageId: messageTs,
969
- emoji: "eyes",
970
- action: "add",
971
- });
962
+ // Already showing before the turn does any work: a DM is addressed to
963
+ // the assistant, so nothing needs to be observed first.
964
+ expect(phases()).toEqual(["thinking"]);
972
965
  return { messageId: "user-msg-dm-immediate" };
973
966
  };
974
967
 
975
968
  processChannelMessageInBackground({
976
969
  processMessage,
977
970
  conversationId,
978
- eventId: "evt-dm-immediate-status",
971
+ eventId: "evt-dm-immediate-activity",
979
972
  content: "dm message",
980
973
  sourceChannel: "slack",
981
974
  sourceInterface: "slack",
@@ -988,39 +981,27 @@ describe("Slack thinking status timing", () => {
988
981
 
989
982
  await flush();
990
983
 
991
- expect(sentReactions.map((entry) => entry.target)).toEqual([
992
- { chatId: channelId, messageId: messageTs, emoji: "eyes", action: "add" },
993
- {
994
- chatId: channelId,
995
- messageId: messageTs,
996
- emoji: "eyes",
997
- action: "remove",
998
- },
999
- ]);
984
+ // `idle` is owed explicitly. A channel that holds its indicator keeps it
985
+ // up until told otherwise, so a missing terminal phase is a stuck spinner
986
+ // rather than a cosmetic slip.
987
+ expect(phases()).toEqual(["thinking", "idle"]);
988
+ expect(sentActivity[0]).toEqual({ chatId: channelId, phase: "thinking" });
1000
989
  });
1001
990
 
1002
- test("sets Slack thinking status immediately for an app mention", async () => {
1003
- const conversationId = "conv-mention-immediate-status";
991
+ test("shows activity immediately for an app mention and settles it", async () => {
992
+ const conversationId = "conv-mention-immediate-activity";
1004
993
  const channelId = "C-MENTION-IMMEDIATE";
1005
994
  const threadTs = "1700000000.000011";
1006
995
 
1007
996
  const processMessage: MessageProcessor = async () => {
1008
- expect(sentThreadStatuses).toHaveLength(1);
1009
- expect(sentThreadStatuses[0]).toEqual({
1010
- chatId: channelId,
1011
- threadTs,
1012
- status: expect.any(String),
1013
- loadingMessages: ["Thinking\u2026"],
1014
- });
1015
- const threadStatus = sentThreadStatuses[0] as { status: string };
1016
- expect(slackStatusLabels).toContain(threadStatus.status);
997
+ expect(phases()).toEqual(["thinking"]);
1017
998
  return { messageId: "user-msg-mention-immediate" };
1018
999
  };
1019
1000
 
1020
1001
  processChannelMessageInBackground({
1021
1002
  processMessage,
1022
1003
  conversationId,
1023
- eventId: "evt-mention-immediate-status",
1004
+ eventId: "evt-mention-immediate-activity",
1024
1005
  content: "@assistant please respond",
1025
1006
  sourceChannel: "slack",
1026
1007
  sourceInterface: "slack",
@@ -1033,204 +1014,123 @@ describe("Slack thinking status timing", () => {
1033
1014
 
1034
1015
  await flush();
1035
1016
 
1036
- const statuses = sentThreadStatuses.map(
1037
- (entry) => (entry as { status?: string }).status,
1038
- );
1039
- expect(slackStatusLabels).toContain(statuses[0]!);
1040
- expect(statuses[1]).toBe("");
1017
+ expect(phases()).toEqual(["thinking", "idle"]);
1041
1018
  });
1042
1019
 
1043
- test("does not set Slack thinking status for no_response text deltas", async () => {
1044
- const conversationId = "conv-no-response-status";
1045
- const channelId = "C-NO-RESPONSE";
1046
- const threadTs = "1700000000.000003";
1047
-
1048
- const processMessage: MessageProcessor = async (
1049
- _conversationId,
1050
- _content,
1051
- options,
1052
- ) => {
1053
- options?.onEvent?.({
1054
- type: "assistant_text_delta",
1055
- text: "<no_response/>",
1056
- conversationId,
1057
- });
1058
- return { messageId: "user-msg-no-response" };
1020
+ test("retries a terminal transition the channel failed to accept", async () => {
1021
+ const conversationId = "conv-idle-retry";
1022
+ const channelId = "D-IDLE-RETRY";
1023
+ let idleAttempts = 0;
1024
+ setChannelActivityImpl = (target) => {
1025
+ if (target.phase === "idle") {
1026
+ idleAttempts += 1;
1027
+ return { ok: idleAttempts > 1 };
1028
+ }
1029
+ return { ok: true };
1059
1030
  };
1060
1031
 
1061
1032
  processChannelMessageInBackground({
1062
- processMessage,
1033
+ processMessage: async () => ({ messageId: "user-msg-idle-retry" }),
1063
1034
  conversationId,
1064
- eventId: "evt-no-response-status",
1065
- content: "ambient channel chatter",
1035
+ eventId: "evt-idle-retry",
1036
+ content: "dm message",
1066
1037
  sourceChannel: "slack",
1067
1038
  sourceInterface: "slack",
1068
1039
  externalChatId: channelId,
1069
1040
  trustCtx,
1070
1041
  metadataHints: [],
1071
- replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&threadTs=${threadTs}`,
1042
+ chatType: "im",
1043
+ replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&messageTs=1700000000.000020`,
1072
1044
  });
1073
1045
 
1074
1046
  await flush();
1075
1047
 
1076
- expect(deliveredChannelReplies).toEqual([]);
1048
+ // A dropped `idle` is not cosmetic on a channel that holds its indicator:
1049
+ // Slack keeps showing the assistant as working for an hour, so the
1050
+ // terminal transition is the one worth attempting twice.
1051
+ expect(idleAttempts).toBe(2);
1077
1052
  });
1078
1053
 
1079
- test("sets and clears Slack thinking status after real assistant text starts", async () => {
1080
- const conversationId = "conv-real-response-status";
1081
- const channelId = "C-REAL-RESPONSE";
1082
- const threadTs = "1700000000.000004";
1083
-
1084
- const processMessage: MessageProcessor = async (
1085
- _conversationId,
1086
- _content,
1087
- options,
1088
- ) => {
1089
- options?.onEvent?.({
1090
- type: "assistant_text_delta",
1091
- text: "<",
1092
- conversationId,
1093
- });
1094
- expect(deliveredChannelReplies).toEqual([]);
1095
-
1096
- options?.onEvent?.({
1097
- type: "assistant_text_delta",
1098
- text: "b>Working on it.",
1099
- conversationId,
1100
- });
1101
- return { messageId: "user-msg-real-response" };
1054
+ test("runs idle next when the turn stops during a slow request, with no stale busy phase between", async () => {
1055
+ const conversationId = "conv-slow-refresh";
1056
+ const channelId = "D-SLOW-REFRESH";
1057
+
1058
+ // A self-expiring channel re-asserts on a timer, which is the only shape
1059
+ // where a refresh backlog can form at all.
1060
+ activityRefreshMsImpl = () => 5;
1061
+
1062
+ let release: (() => void) | undefined;
1063
+ let calls = 0;
1064
+ setChannelActivityImpl = (target) => {
1065
+ calls += 1;
1066
+ if (calls === 1 && target.phase !== "idle") {
1067
+ // Hold the first busy call open across many refresh intervals.
1068
+ return new Promise<{ ok: boolean }>((resolve) => {
1069
+ release = () => resolve({ ok: true });
1070
+ });
1071
+ }
1072
+ return { ok: true };
1102
1073
  };
1103
1074
 
1104
- processChannelMessageInBackground({
1105
- processMessage,
1106
- conversationId,
1107
- eventId: "evt-real-response-status",
1108
- content: "please respond",
1109
- sourceChannel: "slack",
1110
- sourceInterface: "slack",
1111
- externalChatId: channelId,
1112
- trustCtx,
1113
- metadataHints: [],
1114
- replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&threadTs=${threadTs}`,
1115
- });
1116
-
1117
- await flush();
1118
-
1119
- const statuses = sentThreadStatuses.map(
1120
- (entry) => (entry as { status?: string }).status,
1121
- );
1122
- expect(slackStatusLabels).toContain(statuses[0]!);
1123
- expect(statuses[1]).toBe("");
1124
- });
1125
-
1126
- test("buffers task_progress for ambiguous Slack turns until deliverable text appears", async () => {
1127
- const conversationId = "conv-progress-buffered";
1128
- const channelId = "C-PROGRESS-BUFFERED";
1129
- const threadTs = "1700000000.000012";
1130
-
1131
- const processMessage: MessageProcessor = async (
1132
- _conversationId,
1133
- _content,
1134
- options,
1135
- ) => {
1136
- options?.onEvent?.({
1137
- type: "ui_surface_show",
1138
- conversationId,
1139
- surfaceId: "surface-progress",
1140
- surfaceType: "card",
1141
- data: {
1142
- title: "Task progress",
1143
- body: "Working",
1144
- template: "task_progress",
1145
- templateData: {
1146
- steps: [
1147
- { label: "Search docs", status: "in_progress" },
1148
- { label: "Summarize", status: "pending" },
1149
- ],
1150
- },
1151
- },
1152
- });
1153
- expect(deliveredChannelReplies).toEqual([]);
1154
-
1155
- options?.onEvent?.({
1156
- type: "assistant_text_delta",
1157
- text: "I found the answer.",
1158
- conversationId,
1159
- });
1160
- return { messageId: "user-msg-progress-buffered" };
1075
+ const processMessage: MessageProcessor = async () => {
1076
+ // Long enough for several ticks to fire while the first call is unresolved.
1077
+ await new Promise((resolve) => setTimeout(resolve, 40));
1078
+ return { messageId: "user-msg-slow-refresh" };
1161
1079
  };
1162
1080
 
1163
1081
  processChannelMessageInBackground({
1164
1082
  processMessage,
1165
1083
  conversationId,
1166
- eventId: "evt-progress-buffered",
1167
- content: "ambient request",
1084
+ eventId: "evt-slow-refresh",
1085
+ content: "dm message",
1168
1086
  sourceChannel: "slack",
1169
1087
  sourceInterface: "slack",
1170
1088
  externalChatId: channelId,
1171
1089
  trustCtx,
1172
1090
  metadataHints: [],
1173
- replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&threadTs=${threadTs}`,
1091
+ chatType: "im",
1092
+ replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&messageTs=1700000000.000030`,
1174
1093
  });
1175
1094
 
1095
+ await new Promise((resolve) => setTimeout(resolve, 60));
1096
+ release?.();
1176
1097
  await flush();
1177
1098
 
1178
- const statuses = sentThreadStatuses;
1179
- expect(statuses).toEqual([
1180
- {
1181
- chatId: channelId,
1182
- threadTs,
1183
- status: expect.any(String),
1184
- loadingMessages: ["In progress (1/2): Search docs"],
1185
- },
1186
- {
1187
- chatId: channelId,
1188
- threadTs,
1189
- status: "",
1190
- },
1191
- ]);
1192
- expect(slackStatusLabels).toContain(
1193
- (statuses[0] as { status: string }).status,
1194
- );
1099
+ const seen = phases();
1100
+ // The terminal phase is last and nothing busy follows it. A busy phase
1101
+ // executing after `idle` is the visible bug: the indicator comes back on a
1102
+ // turn that has already replied, which teaches people to distrust it.
1103
+ expect(seen.at(-1)).toBe("idle");
1104
+ expect(seen.slice(seen.indexOf("idle") + 1)).toEqual([]);
1105
+ // Ticks did not accumulate behind the outstanding call. Counting rather
1106
+ // than checking the last entry is what catches a backlog that happens to
1107
+ // drain in a harmless order.
1108
+ expect(seen.filter((phase) => phase === "thinking")).toHaveLength(1);
1195
1109
  });
1196
1110
 
1197
- test("keeps ambiguous Slack no_response turns quiet even with task_progress", async () => {
1198
- const conversationId = "conv-progress-no-response";
1199
- const channelId = "C-PROGRESS-NO-RESPONSE";
1200
- const threadTs = "1700000000.000013";
1111
+ test("stays silent for a turn that decides not to answer", async () => {
1112
+ const conversationId = "conv-no-response-activity";
1113
+ const channelId = "C-NO-RESPONSE";
1114
+ const threadTs = "1700000000.000003";
1201
1115
 
1202
1116
  const processMessage: MessageProcessor = async (
1203
1117
  _conversationId,
1204
1118
  _content,
1205
1119
  options,
1206
1120
  ) => {
1207
- options?.onEvent?.({
1208
- type: "ui_surface_show",
1209
- conversationId,
1210
- surfaceId: "surface-progress-no-response",
1211
- surfaceType: "card",
1212
- data: {
1213
- title: "Task progress",
1214
- body: "Working",
1215
- template: "task_progress",
1216
- templateData: {
1217
- steps: [{ label: "Inspect", status: "in_progress" }],
1218
- },
1219
- },
1220
- });
1221
1121
  options?.onEvent?.({
1222
1122
  type: "assistant_text_delta",
1223
1123
  text: "<no_response/>",
1224
1124
  conversationId,
1225
1125
  });
1226
- return { messageId: "user-msg-progress-no-response" };
1126
+ return { messageId: "user-msg-no-response" };
1227
1127
  };
1228
1128
 
1229
1129
  processChannelMessageInBackground({
1230
1130
  processMessage,
1231
1131
  conversationId,
1232
- eventId: "evt-progress-no-response",
1233
- content: "ambient chatter",
1132
+ eventId: "evt-no-response-activity",
1133
+ content: "ambient channel chatter",
1234
1134
  sourceChannel: "slack",
1235
1135
  sourceInterface: "slack",
1236
1136
  externalChatId: channelId,
@@ -1241,61 +1141,44 @@ describe("Slack thinking status timing", () => {
1241
1141
 
1242
1142
  await flush();
1243
1143
 
1144
+ // Not merely "no spinner": nothing at all was said to the channel. An
1145
+ // `idle` here would still be a message about a turn nobody asked to see,
1146
+ // and in a shared room it announces that the assistant read the traffic.
1147
+ expect(sentActivity).toEqual([]);
1244
1148
  expect(deliveredChannelReplies).toEqual([]);
1245
1149
  });
1246
1150
 
1247
- test("updates Slack loading message when task_progress changes", async () => {
1248
- const conversationId = "conv-progress-update";
1249
- const channelId = "C-PROGRESS-UPDATE";
1250
- const threadTs = "1700000000.000014";
1151
+ test("waits for real text in a room it was not addressed in, then settles", async () => {
1152
+ const conversationId = "conv-real-response-activity";
1153
+ const channelId = "C-REAL-RESPONSE";
1154
+ const threadTs = "1700000000.000004";
1251
1155
 
1252
1156
  const processMessage: MessageProcessor = async (
1253
1157
  _conversationId,
1254
1158
  _content,
1255
1159
  options,
1256
1160
  ) => {
1257
- options?.onEvent?.({
1258
- type: "ui_surface_show",
1259
- conversationId,
1260
- surfaceId: "surface-progress-update",
1261
- surfaceType: "card",
1262
- data: {
1263
- title: "Task progress",
1264
- body: "Working",
1265
- template: "task_progress",
1266
- templateData: {
1267
- steps: [
1268
- { label: "Read request", status: "in_progress" },
1269
- { label: "Write answer", status: "pending" },
1270
- ],
1271
- },
1272
- },
1273
- });
1274
1161
  options?.onEvent?.({
1275
1162
  type: "assistant_text_delta",
1276
- text: "On it.",
1163
+ text: "<",
1277
1164
  conversationId,
1278
1165
  });
1166
+ // A partial `<` could still become `<no_response/>`, so nothing shows.
1167
+ expect(sentActivity).toEqual([]);
1168
+
1279
1169
  options?.onEvent?.({
1280
- type: "ui_surface_update",
1170
+ type: "assistant_text_delta",
1171
+ text: "b>Working on it.",
1281
1172
  conversationId,
1282
- surfaceId: "surface-progress-update",
1283
- data: {
1284
- templateData: {
1285
- steps: [
1286
- { label: "Read request", status: "completed" },
1287
- { label: "Write answer", status: "in_progress" },
1288
- ],
1289
- },
1290
- },
1291
1173
  });
1292
- return { messageId: "user-msg-progress-update" };
1174
+ expect(phases()).toEqual(["thinking"]);
1175
+ return { messageId: "user-msg-real-response" };
1293
1176
  };
1294
1177
 
1295
1178
  processChannelMessageInBackground({
1296
1179
  processMessage,
1297
1180
  conversationId,
1298
- eventId: "evt-progress-update",
1181
+ eventId: "evt-real-response-activity",
1299
1182
  content: "please respond",
1300
1183
  sourceChannel: "slack",
1301
1184
  sourceInterface: "slack",
@@ -1307,31 +1190,6 @@ describe("Slack thinking status timing", () => {
1307
1190
 
1308
1191
  await flush();
1309
1192
 
1310
- const statuses = sentThreadStatuses;
1311
- expect(statuses).toEqual([
1312
- {
1313
- chatId: channelId,
1314
- threadTs,
1315
- status: expect.any(String),
1316
- loadingMessages: ["In progress (1/2): Read request"],
1317
- },
1318
- {
1319
- chatId: channelId,
1320
- threadTs,
1321
- status: expect.any(String),
1322
- loadingMessages: ["In progress (2/2): Write answer"],
1323
- },
1324
- {
1325
- chatId: channelId,
1326
- threadTs,
1327
- status: "",
1328
- },
1329
- ]);
1330
- expect(slackStatusLabels).toContain(
1331
- (statuses[0] as { status: string }).status,
1332
- );
1333
- expect(slackStatusLabels).toContain(
1334
- (statuses[1] as { status: string }).status,
1335
- );
1193
+ expect(phases()).toEqual(["thinking", "idle"]);
1336
1194
  });
1337
1195
  });