@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
@@ -9,9 +9,7 @@ const slack = {
9
9
  Promise.resolve({ ts: "slack-ts" }),
10
10
  ),
11
11
  sendSlackReaction: mock((..._args: unknown[]) => Promise.resolve()),
12
- sendSlackAssistantThreadStatus: mock((..._args: unknown[]) =>
13
- Promise.resolve(),
14
- ),
12
+ sendSlackAgentSessionStatus: mock((..._args: unknown[]) => Promise.resolve()),
15
13
  sendSlackAttachments: mock((..._args: unknown[]) =>
16
14
  Promise.resolve({ allFailed: false, failureCount: 0 }),
17
15
  ),
@@ -23,6 +21,7 @@ const slack = {
23
21
  ),
24
22
  };
25
23
  const telegram = {
24
+ editTelegramMessage: mock((..._args: unknown[]) => Promise.resolve()),
26
25
  sendTelegramReply: mock((..._args: unknown[]) => Promise.resolve()),
27
26
  sendTelegramRichReply: mock((..._args: unknown[]) => Promise.resolve()),
28
27
  sendTelegramTypingIndicator: mock((..._args: unknown[]) => Promise.resolve()),
@@ -46,6 +45,7 @@ const discord = {
46
45
  sendDiscordTypingIndicator: mock((..._args: unknown[]) =>
47
46
  Promise.resolve(true),
48
47
  ),
48
+ editDiscordMessage: mock((..._args: unknown[]) => Promise.resolve()),
49
49
  sendDiscordAttachments: mock((..._args: unknown[]) =>
50
50
  Promise.resolve({ allFailed: false, failureCount: 0, totalCount: 0 }),
51
51
  ),
@@ -63,11 +63,9 @@ mock.module("../../../util/logger.js", () => ({
63
63
  const {
64
64
  deliverDirect,
65
65
  editChannelMessage,
66
- sendChannelReaction,
67
66
  sendChannelStreamOp,
68
- sendChannelTyping,
69
- setChannelThreadStatus,
70
- supportsChannelTyping,
67
+ setChannelActivity,
68
+ supportsChannelActivity,
71
69
  isDirectDelivery,
72
70
  getTransportForCallback,
73
71
  } = await import("../index.js");
@@ -146,18 +144,6 @@ describe("Slack sub-operation selection", () => {
146
144
  expect(opts.threadTs).toBe("1700.9");
147
145
  });
148
146
 
149
- test("sendChannelReaction reaches Slack without touching the text path", async () => {
150
- await sendChannelReaction(`${BASE}/deliver/slack`, {
151
- chatId: "C1",
152
- messageId: "1700.5",
153
- emoji: "white_check_mark",
154
- action: "add",
155
- });
156
-
157
- expect(slack.sendSlackReaction).toHaveBeenCalledTimes(1);
158
- expect(slack.sendSlackReply).not.toHaveBeenCalled();
159
- });
160
-
161
147
  test("editChannelMessage updates in place instead of posting", async () => {
162
148
  await editChannelMessage(`${BASE}/deliver/slack`, {
163
149
  chatId: "C1",
@@ -171,31 +157,22 @@ describe("Slack sub-operation selection", () => {
171
157
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
172
158
  });
173
159
 
174
- test("setChannelThreadStatus reaches Slack without touching the text path", async () => {
175
- await setChannelThreadStatus(`${BASE}/deliver/slack`, {
160
+ test("setChannelActivity reaches Slack without touching the text path", async () => {
161
+ await setChannelActivity(`${BASE}/deliver/slack`, {
176
162
  chatId: "C1",
177
- threadTs: "1700.5",
178
- status: "is thinking",
163
+ phase: "thinking",
179
164
  });
180
165
 
181
- expect(slack.sendSlackAssistantThreadStatus).toHaveBeenCalledTimes(1);
166
+ expect(slack.sendSlackAgentSessionStatus).toHaveBeenCalledTimes(1);
182
167
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
183
168
  });
184
169
 
185
- test("a channel with no status surface resolves quietly", async () => {
186
- const result = await setChannelThreadStatus(`${BASE}/deliver/telegram`, {
170
+ test("a channel with no activity indicator resolves quietly", async () => {
171
+ const result = await setChannelActivity(`${BASE}/deliver/whatsapp`, {
187
172
  chatId: "123",
188
- threadTs: "1700.5",
189
- status: "is thinking",
173
+ phase: "thinking",
190
174
  });
191
175
 
192
- expect(result).toEqual({ ok: true });
193
- expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
194
- });
195
-
196
- test("sendChannelTyping resolves quietly for a channel with no typing capability", async () => {
197
- const result = await sendChannelTyping(`${BASE}/deliver/slack`, "C1");
198
-
199
176
  expect(result).toEqual({ ok: true });
200
177
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
201
178
  });
@@ -204,12 +181,22 @@ describe("Slack sub-operation selection", () => {
204
181
  const result = await sendChannelStreamOp(
205
182
  `${BASE}/deliver/slack?threadTs=1700.5`,
206
183
  "C1",
207
- { action: "start", threadTs: "1700.5", markdownText: "hi" },
184
+ {
185
+ action: "start",
186
+ anchorMessageId: "1700.5",
187
+ text: "hi",
188
+ appended: "hi",
189
+ },
208
190
  );
209
191
  expect(slack.sendSlackStreamOp).toHaveBeenCalledTimes(1);
210
192
  expect(slack.sendSlackStreamOp.mock.calls[0]).toEqual([
211
193
  "C1",
212
- { action: "start", threadTs: "1700.5", markdownText: "hi" },
194
+ {
195
+ action: "start",
196
+ anchorMessageId: "1700.5",
197
+ text: "hi",
198
+ appended: "hi",
199
+ },
213
200
  ]);
214
201
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
215
202
  expect(result).toEqual({ ok: true, ts: "stream-ts" });
@@ -217,48 +204,115 @@ describe("Slack sub-operation selection", () => {
217
204
  });
218
205
 
219
206
  describe("capability gating across channels", () => {
220
- test("a channel with no reaction capability resolves quietly", async () => {
221
- // Slack is the only channel that implements it, because the only producer
222
- // is Slack's own acknowledgement fallback. A channel without the method
223
- // is not a failed delivery, so nothing is attempted and nothing throws.
224
- const target = {
207
+ test("Slack renders a muted edit as its own context block", async () => {
208
+ await editChannelMessage(`${BASE}/deliver/slack`, {
225
209
  chatId: "C1",
226
- messageId: "1",
227
- emoji: "eyes",
228
- action: "add",
229
- } as const;
230
- expect(
231
- await sendChannelReaction(`${BASE}/deliver/telegram`, target),
232
- ).toEqual({ ok: true });
210
+ messageId: "1700.5",
211
+ text: "This approval request has been resolved.",
212
+ emphasis: "muted",
213
+ });
214
+
215
+ // The producer asked for a settled message and named no markup. Slack
216
+ // decides that means a context block.
217
+ const [, , , options] = slack.updateSlackMessage.mock.calls[0]!;
218
+ expect((options as { blocks?: unknown[] }).blocks).toEqual([
219
+ {
220
+ type: "context",
221
+ elements: [
222
+ {
223
+ type: "mrkdwn",
224
+ text: "This approval request has been resolved.",
225
+ },
226
+ ],
227
+ },
228
+ ]);
229
+ });
230
+
231
+ test("an edit with no emphasis carries no channel markup", async () => {
232
+ await editChannelMessage(`${BASE}/deliver/slack`, {
233
+ chatId: "C1",
234
+ messageId: "1700.5",
235
+ text: "revised",
236
+ });
237
+
238
+ const [, , , options] = slack.updateSlackMessage.mock.calls[0]!;
239
+ expect((options as { blocks?: unknown[] }).blocks).toBeUndefined();
240
+ });
241
+
242
+ test("editChannelMessage now reaches Telegram, not only Slack", async () => {
243
+ // The capability gate is what let Telegram gain this: no caller changed,
244
+ // the transport grew the method.
245
+ await editChannelMessage(`${BASE}/deliver/telegram`, {
246
+ chatId: "123",
247
+ messageId: "456",
248
+ text: "revised",
249
+ });
250
+
251
+ expect(telegram.editTelegramMessage).toHaveBeenCalledTimes(1);
233
252
  expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
234
253
  });
235
254
 
236
255
  test("a channel that cannot revise a sent message resolves quietly", async () => {
237
- // Only Slack implements `edit` today. A channel without the method is not
238
- // a failed delivery: nothing is attempted, nothing throws, and no fresh
239
- // message is posted in place of the revision.
256
+ // WhatsApp has no `edit`. A channel without the method is not a failed
257
+ // delivery: nothing is attempted, nothing throws, and no fresh message is
258
+ // posted in place of the revision.
240
259
  expect(
241
- await editChannelMessage(`${BASE}/deliver/telegram`, {
260
+ await editChannelMessage(`${BASE}/deliver/whatsapp`, {
242
261
  chatId: "C1",
243
262
  messageId: "1",
244
263
  text: "revised",
245
264
  }),
246
265
  ).toEqual({ ok: true });
247
- expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
266
+ expect(whatsapp.sendWhatsAppReply).not.toHaveBeenCalled();
267
+ });
268
+
269
+ test("Discord edits in place instead of posting", async () => {
270
+ await editChannelMessage(`${BASE}/deliver/discord`, {
271
+ chatId: "C1",
272
+ messageId: "M9",
273
+ text: "revised",
274
+ });
275
+
276
+ expect(discord.editDiscordMessage).toHaveBeenCalledTimes(1);
277
+ // Same distinction the Slack case asserts: an edit never reaches the post
278
+ // path, so a failed edit cannot become a second visible message.
279
+ expect(discord.sendDiscordReply).not.toHaveBeenCalled();
248
280
  });
249
281
 
250
- test("the typing capability is read from the transport, not the channel name", () => {
251
- // The heartbeat gate in background-dispatch asks this rather than testing
252
- // `sourceChannel === "telegram"`, so a channel that implements the method
253
- // starts showing an indicator without a caller being changed.
254
- expect(supportsChannelTyping(`${BASE}/deliver/telegram`)).toBe(true);
255
- expect(supportsChannelTyping(`${BASE}/deliver/discord`)).toBe(true);
256
- expect(supportsChannelTyping(`${BASE}/deliver/slack`)).toBe(false);
257
- expect(supportsChannelTyping(`${BASE}/deliver/whatsapp`)).toBe(false);
282
+ test("Discord renders a muted edit as subtext", async () => {
283
+ await editChannelMessage(`${BASE}/deliver/discord`, {
284
+ chatId: "C1",
285
+ messageId: "M9",
286
+ text: "This approval request has been resolved.",
287
+ emphasis: "muted",
288
+ });
289
+
290
+ // `muted` is surface-agnostic and each channel picks its own token. Slack
291
+ // uses a context block; Discord's nearest equivalent is subtext, which it
292
+ // renders at the size and colour of a dismiss line.
293
+ expect(discord.editDiscordMessage.mock.calls[0][3]).toEqual({
294
+ emphasis: "muted",
295
+ });
296
+ });
297
+
298
+ test("the activity capability is read from the transport, not the channel name", () => {
299
+ // The gate in background-dispatch asks this rather than testing
300
+ // `sourceChannel === "slack"`, so a channel that implements the method
301
+ // starts showing an indicator without a caller being changed. All three
302
+ // answer the same question now, which is the point of the single method:
303
+ // Slack holds its indicator and the other two re-assert theirs, and a
304
+ // caller cannot tell the difference.
305
+ expect(supportsChannelActivity(`${BASE}/deliver/slack`)).toBe(true);
306
+ expect(supportsChannelActivity(`${BASE}/deliver/telegram`)).toBe(true);
307
+ expect(supportsChannelActivity(`${BASE}/deliver/discord`)).toBe(true);
308
+ expect(supportsChannelActivity(`${BASE}/deliver/whatsapp`)).toBe(false);
258
309
  });
259
310
 
260
- test("sendChannelTyping reaches Telegram's typing indicator", async () => {
261
- await sendChannelTyping(`${BASE}/deliver/telegram`, "123");
311
+ test("setChannelActivity reaches Telegram's typing indicator", async () => {
312
+ await setChannelActivity(`${BASE}/deliver/telegram`, {
313
+ chatId: "123",
314
+ phase: "thinking",
315
+ });
262
316
 
263
317
  expect(telegram.sendTelegramTypingIndicator).toHaveBeenCalledTimes(1);
264
318
  });
@@ -297,8 +351,11 @@ describe("capability gating across channels", () => {
297
351
  });
298
352
  });
299
353
 
300
- test("sendChannelTyping reaches Discord's typing indicator", async () => {
301
- await sendChannelTyping(`${BASE}/deliver/discord`, "999");
354
+ test("setChannelActivity reaches Discord's typing indicator", async () => {
355
+ await setChannelActivity(`${BASE}/deliver/discord`, {
356
+ chatId: "999",
357
+ phase: "thinking",
358
+ });
302
359
 
303
360
  expect(discord.sendDiscordTypingIndicator).toHaveBeenCalledTimes(1);
304
361
  });
@@ -306,8 +363,9 @@ describe("capability gating across channels", () => {
306
363
  test("a channel that cannot stream resolves quietly, and posts nothing", async () => {
307
364
  const result = await sendChannelStreamOp(`${BASE}/deliver/discord`, "999", {
308
365
  action: "start",
309
- threadTs: "1700.5",
310
- markdownText: "hi",
366
+ anchorMessageId: "1700.5",
367
+ text: "hi",
368
+ appended: "hi",
311
369
  });
312
370
 
313
371
  expect(result).toEqual({ ok: true });
@@ -1,10 +1,10 @@
1
- import type { KnownBlock } from "@slack/types";
2
1
  import type {
3
2
  ChannelDeliveryResult,
4
3
  ChannelReplyPayload,
5
- SlackStreamOp,
4
+ StreamOp,
6
5
  } from "@vellumai/gateway-client";
7
6
 
7
+ import type { AssistantActivityPhase } from "../../api/index.js";
8
8
  import type { ChannelId } from "../../channels/types.js";
9
9
 
10
10
  /**
@@ -17,32 +17,43 @@ export interface CallbackContext {
17
17
  readonly params: Readonly<Record<string, string>>;
18
18
  }
19
19
 
20
- /** The message an emoji reaction lands on, and what to do there. */
21
- export interface ReactionTarget {
20
+ /**
21
+ * How busy the assistant is in one conversation, for the channel to show.
22
+ *
23
+ * `phase` is the daemon's own activity phase rather than a channel's word for
24
+ * it, so every channel is told the same lifecycle and decides how to render
25
+ * it. A channel whose indicator expires on its own re-sends while the busy
26
+ * phases last; a channel whose indicator holds sets it once and clears it on
27
+ * `idle`. Neither shape leaks into what the caller says.
28
+ */
29
+ export interface ActivityTarget {
22
30
  readonly chatId: string;
23
- /** The target message, in the channel's own id space. */
24
- readonly messageId: string;
25
- readonly emoji: string;
26
- readonly action: "add" | "remove";
31
+ readonly phase: AssistantActivityPhase;
32
+ /**
33
+ * Who started the turn. Channels that attribute a session to a person read
34
+ * it when the session is created, and ignore it afterwards.
35
+ */
36
+ readonly initiatorUserId?: string;
27
37
  }
28
38
 
29
39
  /**
30
- * A status surface update. `chatId` is the room, spelled the way every other
31
- * method on this interface spells it.
40
+ * Whether a phase means a turn is actually running.
41
+ *
42
+ * A channel with a plain busy indicator shows it for these and nothing else.
43
+ * `awaiting_confirmation` is deliberately not one: an approval is waiting on a
44
+ * person, and a typing bubble there promises output that is not coming.
32
45
  */
33
- export interface ThreadStatus {
34
- readonly chatId: string;
35
- readonly threadTs: string;
36
- readonly status: string;
37
- /** Rotating hints shown under the status while it holds. */
38
- readonly loadingMessages?: readonly string[];
46
+ export function isBusyActivityPhase(phase: AssistantActivityPhase): boolean {
47
+ return (
48
+ phase === "thinking" || phase === "streaming" || phase === "tool_running"
49
+ );
39
50
  }
40
51
 
41
52
  /**
42
53
  * An existing message to replace in place, and what to replace it with.
43
54
  *
44
55
  * `messageId` is the target in the channel's own id space, the same way
45
- * `chatId` is. `blocks` and `useBlocks` are the rendering inputs the edit
56
+ * `chatId` is. `renderRichly` and `emphasis` are the rendering inputs the edit
46
57
  * carries, so a replacement renders the way the original did rather than
47
58
  * degrading to plain text.
48
59
  */
@@ -50,8 +61,14 @@ export interface EditTarget {
50
61
  readonly chatId: string;
51
62
  readonly messageId: string;
52
63
  readonly text: string;
53
- readonly blocks?: readonly KnownBlock[];
54
- readonly useBlocks?: boolean;
64
+ readonly renderRichly?: boolean;
65
+ /**
66
+ * How prominent the replacement should read. Surface-agnostic, the way
67
+ * `ApprovalActionOption.emphasis` is: each channel translates it to its own
68
+ * token, and one with no equivalent renders plain text. `muted` marks a
69
+ * message as settled rather than current.
70
+ */
71
+ readonly emphasis?: "muted";
55
72
  }
56
73
 
57
74
  /**
@@ -91,48 +108,48 @@ export interface ChannelTransport {
91
108
  ): Promise<ChannelDeliveryResult>;
92
109
 
93
110
  /**
94
- * Show that the assistant is working, in whatever form the channel has.
95
- *
96
- * Takes the place and nothing else, because that is all it needs. A channel
97
- * without the affordance omits the method, so implementing it is the whole
98
- * of declaring the capability.
99
- */
100
- typing?(ctx: CallbackContext, chatId: string): Promise<ChannelDeliveryResult>;
101
-
102
- /**
103
- * Add or remove one of the assistant's own emoji reactions on a message.
111
+ * Show how busy the assistant is, in whatever form the channel has.
104
112
  *
105
- * `messageId` is the target's id in the channel's own space, the same way
106
- * `chatId` is: Slack spells it as a timestamp, Discord and Telegram as ids.
107
- * Nothing outside the channel reads it, so nothing outside needs a shared
108
- * spelling for it.
113
+ * One capability rather than one per indicator shape: a self-expiring typing
114
+ * bubble and a status that holds until cleared answer the same question, and
115
+ * splitting them by how the channel keeps them alive forces every caller to
116
+ * know which channel it is talking to. A channel without any such affordance
117
+ * omits the method, so implementing it is the whole of declaring it.
109
118
  */
110
- react?(
119
+ setActivity?(
111
120
  ctx: CallbackContext,
112
- target: ReactionTarget,
121
+ target: ActivityTarget,
113
122
  ): Promise<ChannelDeliveryResult>;
114
123
 
115
124
  /**
116
- * Set or clear the channel's own "working on it" surface.
125
+ * How often a busy indicator must be re-asserted to stay visible, for a
126
+ * channel whose indicator expires on its own.
117
127
  *
118
- * Distinct from `typing`: an indicator a channel refreshes on a timer versus
119
- * a status a channel holds until it is changed. Slack has both and uses this
120
- * one; a channel with only the first implements only `typing`.
128
+ * Omitted by a channel whose indicator holds until it is changed, which is
129
+ * how a caller knows to set it once rather than run a timer. Declaring the
130
+ * cadence here keeps the caller from having to know which channel it is.
121
131
  */
122
- setThreadStatus?(
123
- ctx: CallbackContext,
124
- status: ThreadStatus,
125
- ): Promise<ChannelDeliveryResult>;
132
+ readonly activityRefreshMs?: number;
126
133
 
127
134
  /**
128
- * Advance a streamed reply: open it, add to it, or close it.
135
+ * Advance a reply that grows while the turn runs: open it, add to it, or end
136
+ * it.
137
+ *
138
+ * A channel with no primitive for this omits the method, and the caller
139
+ * sends the finished reply instead. Omitting it is the correct answer for a
140
+ * platform that offers nothing here rather than a gap to paper over: a
141
+ * channel is not made to simulate streaming by rewriting a sent message,
142
+ * which is both a worse experience and, on Telegram, discouraged by the
143
+ * platform that does offer one.
129
144
  *
130
- * A channel that can only post a finished message omits this, and the
131
- * caller falls back to delivering the reply whole.
145
+ * What the channel leaves behind when the stream ends is its own business.
146
+ * Slack finalizes the streamed message in place; Telegram's live draft
147
+ * expires and the reply is persisted by an ordinary send. `stop` carries the
148
+ * complete reply so either can be true.
132
149
  */
133
150
  streamReply?(
134
151
  ctx: CallbackContext,
135
152
  chatId: string,
136
- op: SlackStreamOp,
153
+ op: StreamOp,
137
154
  ): Promise<ChannelDeliveryResult>;
138
155
  }
@@ -30,7 +30,8 @@ mock.module("../../../util/logger.js", () => ({
30
30
  getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
31
31
  }));
32
32
 
33
- const { sendDiscordReply, sendDiscordAttachments } = await import("./send.js");
33
+ const { sendDiscordReply, sendDiscordAttachments, editDiscordMessage } =
34
+ await import("./send.js");
34
35
 
35
36
  const originalFetch = globalThis.fetch;
36
37
 
@@ -177,3 +178,47 @@ describe("sendDiscordAttachments", () => {
177
178
  expect(calls.every((c) => typeof c.body === "string")).toBe(true);
178
179
  });
179
180
  });
181
+
182
+ describe("editDiscordMessage", () => {
183
+ const bodyOf = (index = 0): Record<string, unknown> =>
184
+ JSON.parse(String(calls[index]?.body)) as Record<string, unknown>;
185
+
186
+ test("patches the message rather than posting a new one", async () => {
187
+ await editDiscordMessage({ channelId: "C1" }, "M9", "revised");
188
+
189
+ expect(calls).toHaveLength(1);
190
+ expect(calls[0]?.method).toBe("PATCH");
191
+ expect(calls[0]?.url).toContain("/channels/C1/messages/M9");
192
+ expect(bodyOf().content).toBe("revised");
193
+ });
194
+
195
+ test("marks every line of a muted edit, not just the first", async () => {
196
+ // Discord's subtext applies per line, so one marker would render the first
197
+ // line small and leave the rest at body size.
198
+ await editDiscordMessage({ channelId: "C1" }, "M9", "Resolved.\nBy Ada.", {
199
+ emphasis: "muted",
200
+ });
201
+
202
+ expect(bodyOf().content).toBe("-# Resolved.\n-# By Ada.");
203
+ });
204
+
205
+ test("leaves a blank line alone", async () => {
206
+ // A bare `-# ` renders an empty subtext line rather than a gap.
207
+ await editDiscordMessage({ channelId: "C1" }, "M9", "One.\n\nTwo.", {
208
+ emphasis: "muted",
209
+ });
210
+
211
+ expect(bodyOf().content).toBe("-# One.\n\n-# Two.");
212
+ });
213
+
214
+ test("a rejected edit throws rather than posting a replacement", async () => {
215
+ // The reason editing is its own capability: falling back to a post would
216
+ // leave the original standing beside a duplicate.
217
+ stubFetch(400, { message: "Invalid Form Body" });
218
+
219
+ await expect(
220
+ editDiscordMessage({ channelId: "C1" }, "M9", "revised"),
221
+ ).rejects.toThrow();
222
+ expect(calls.every((call) => call.method === "PATCH")).toBe(true);
223
+ });
224
+ });
@@ -65,6 +65,57 @@ export interface DiscordSendResult {
65
65
  lastMessageId?: string;
66
66
  }
67
67
 
68
+ /**
69
+ * Discord's rendering of a settled message.
70
+ *
71
+ * `-# ` is Discord's subtext markdown, which it describes as the same size and
72
+ * colour as the dismiss line under a bot message. It is the closest thing
73
+ * Discord has to Slack's context block, and it applies per line, so every line
74
+ * carries the marker rather than only the first.
75
+ *
76
+ * @see https://support.discord.com/hc/en-us/articles/210298617
77
+ */
78
+ function mutedText(text: string): string {
79
+ return text
80
+ .split("\n")
81
+ .map((line) => (line.trim().length === 0 ? line : `-# ${line}`))
82
+ .join("\n");
83
+ }
84
+
85
+ /**
86
+ * Replace a message the assistant already sent.
87
+ *
88
+ * Never posts. A failed edit throws so the original stands alone rather than
89
+ * gaining a duplicate beside it, which is the whole reason editing is its own
90
+ * capability.
91
+ *
92
+ * Deliberately does not chunk. An edit addresses one message, and a
93
+ * replacement too long for one is a condition the caller has to know about
94
+ * rather than one this can paper over by dropping the tail. Discord rejects an
95
+ * over-length body and that rejection propagates.
96
+ *
97
+ * @see https://discord.com/developers/docs/resources/message#edit-message
98
+ */
99
+ export async function editDiscordMessage(
100
+ target: DiscordSendTarget,
101
+ messageId: string,
102
+ text: string,
103
+ options?: { emphasis?: "muted" },
104
+ ): Promise<void> {
105
+ await callDiscordApi<DiscordMessage>(
106
+ "PATCH",
107
+ `${messagesRoute(target)}/${encodeURIComponent(messageId)}`,
108
+ {
109
+ content: options?.emphasis === "muted" ? mutedText(text) : text,
110
+ allowed_mentions: DISCORD_ALLOWED_MENTIONS,
111
+ },
112
+ );
113
+ log.debug(
114
+ { channelId: target.channelId, messageId },
115
+ "Discord message edited",
116
+ );
117
+ }
118
+
68
119
  /**
69
120
  * Send a text reply, split across as many messages as Discord's content cap
70
121
  * requires. Chunks post in order so the reply reads top to bottom.
@@ -5,9 +5,11 @@ import type {
5
5
  CallbackContext,
6
6
  ChannelTransport,
7
7
  } from "../channel-transport.js";
8
+ import { isBusyActivityPhase } from "../channel-transport.js";
8
9
  import { openDiscordDmChannel } from "./api.js";
9
10
  import type { DiscordSendTarget } from "./send.js";
10
11
  import {
12
+ editDiscordMessage,
11
13
  sendDiscordAttachments,
12
14
  sendDiscordReply,
13
15
  sendDiscordTypingIndicator,
@@ -47,9 +49,30 @@ async function sendTarget(
47
49
  export const discordTransport: ChannelTransport = {
48
50
  channel: "discord",
49
51
 
50
- async typing(ctx, chatId) {
51
- await sendDiscordTypingIndicator(await sendTarget(ctx, chatId));
52
- log.debug({ chatId }, "Discord typing indicator delivered (direct)");
52
+ // Discord clears a typing indicator after ten seconds.
53
+ activityRefreshMs: 8_000,
54
+
55
+ async edit(ctx, target) {
56
+ await editDiscordMessage(
57
+ await sendTarget(ctx, target.chatId),
58
+ target.messageId,
59
+ target.text,
60
+ { ...(target.emphasis ? { emphasis: target.emphasis } : {}) },
61
+ );
62
+ return { ok: true };
63
+ },
64
+
65
+ async setActivity(ctx, target) {
66
+ // Discord's typing indicator expires by itself after ten seconds, so a
67
+ // phase that is not running needs no clearing call.
68
+ if (!isBusyActivityPhase(target.phase)) {
69
+ return { ok: true };
70
+ }
71
+ await sendDiscordTypingIndicator(await sendTarget(ctx, target.chatId));
72
+ log.debug(
73
+ { chatId: target.chatId, phase: target.phase },
74
+ "Discord typing indicator delivered (direct)",
75
+ );
53
76
  return { ok: true };
54
77
  },
55
78