@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
@@ -30,7 +30,8 @@ mock.module("./api.js", () => ({
30
30
  }));
31
31
 
32
32
  const { TelegramNonRetryableError } = await import("./api.js");
33
- const { sendTelegramReply, sendTelegramRichReply } = await import("./send.js");
33
+ const { editTelegramMessage, sendTelegramReply, sendTelegramRichReply } =
34
+ await import("./send.js");
34
35
  const { telegramTransport } = await import("./transport.js");
35
36
 
36
37
  const approval: ApprovalUIMetadata = {
@@ -191,15 +192,15 @@ describe("telegramTransport.deliver routing", () => {
191
192
  } as ChannelReplyPayload;
192
193
  }
193
194
 
194
- test("routes to the rich send when useBlocks is set", async () => {
195
- await telegramTransport.deliver(ctx, payload({ useBlocks: true }));
195
+ test("routes to the rich send when a rich render is asked for", async () => {
196
+ await telegramTransport.deliver(ctx, payload({ renderRichly: true }));
196
197
 
197
198
  expect(callsTo("sendRichMessage")).toHaveLength(1);
198
199
  expect(callsTo("sendMessage")).toHaveLength(0);
199
200
  });
200
201
 
201
- test("stays on the plain send when useBlocks is absent", async () => {
202
- await telegramTransport.deliver(ctx, payload({ useBlocks: false }));
202
+ test("stays on the plain send when it is not", async () => {
203
+ await telegramTransport.deliver(ctx, payload({ renderRichly: false }));
203
204
 
204
205
  expect(callsTo("sendRichMessage")).toHaveLength(0);
205
206
  expect(callsTo("sendMessage")).toHaveLength(1);
@@ -208,7 +209,7 @@ describe("telegramTransport.deliver routing", () => {
208
209
  test("forwards approval metadata through the rich path", async () => {
209
210
  await telegramTransport.deliver(
210
211
  ctx,
211
- payload({ useBlocks: true, approval } as Partial<ChannelReplyPayload>),
212
+ payload({ renderRichly: true, approval } as Partial<ChannelReplyPayload>),
212
213
  );
213
214
 
214
215
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendRichMessage", {
@@ -238,7 +239,7 @@ describe("telegramTransport topic targeting", () => {
238
239
  }
239
240
 
240
241
  test("plain replies target the topic from the callback threadId param", async () => {
241
- await telegramTransport.deliver(topicCtx, payload({ useBlocks: false }));
242
+ await telegramTransport.deliver(topicCtx, payload({ renderRichly: false }));
242
243
 
243
244
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendMessage", {
244
245
  chat_id: "123",
@@ -248,7 +249,7 @@ describe("telegramTransport topic targeting", () => {
248
249
  });
249
250
 
250
251
  test("rich replies target the topic", async () => {
251
- await telegramTransport.deliver(topicCtx, payload({ useBlocks: true }));
252
+ await telegramTransport.deliver(topicCtx, payload({ renderRichly: true }));
252
253
 
253
254
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendRichMessage", {
254
255
  chat_id: "123",
@@ -262,7 +263,7 @@ describe("telegramTransport topic targeting", () => {
262
263
  throw new TelegramNonRetryableError("rejected", "rejected");
263
264
  });
264
265
 
265
- await telegramTransport.deliver(topicCtx, payload({ useBlocks: true }));
266
+ await telegramTransport.deliver(topicCtx, payload({ renderRichly: true }));
266
267
 
267
268
  expect(callTelegramBotApiMock).toHaveBeenNthCalledWith(2, "sendMessage", {
268
269
  chat_id: "123",
@@ -272,7 +273,10 @@ describe("telegramTransport topic targeting", () => {
272
273
  });
273
274
 
274
275
  test("typing indicators target the topic", async () => {
275
- await telegramTransport.typing!(topicCtx, "123");
276
+ await telegramTransport.setActivity!(topicCtx, {
277
+ chatId: "123",
278
+ phase: "thinking",
279
+ });
276
280
 
277
281
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendChatAction", {
278
282
  chat_id: "123",
@@ -281,14 +285,32 @@ describe("telegramTransport topic targeting", () => {
281
285
  });
282
286
  });
283
287
 
288
+ // Telegram's chat action expires on its own, so a phase that is not running
289
+ // has nothing to say. Asserting the call count rather than the absence of a
290
+ // "stop" call is what catches a clearing request being invented later.
291
+ test.each(["idle", "awaiting_confirmation"] as const)(
292
+ "says nothing to Telegram for the %s phase",
293
+ async (phase) => {
294
+ await telegramTransport.setActivity!(topicCtx, {
295
+ chatId: "123",
296
+ phase,
297
+ });
298
+
299
+ expect(callTelegramBotApiMock).not.toHaveBeenCalled();
300
+ },
301
+ );
302
+
284
303
  test("a callback URL without threadId keeps sends thread-less", async () => {
285
304
  const bareCtx: CallbackContext = {
286
305
  callbackUrl: "/deliver/telegram",
287
306
  params: {},
288
307
  };
289
308
 
290
- await telegramTransport.deliver(bareCtx, payload({ useBlocks: false }));
291
- await telegramTransport.typing!(bareCtx, "123");
309
+ await telegramTransport.deliver(bareCtx, payload({ renderRichly: false }));
310
+ await telegramTransport.setActivity!(bareCtx, {
311
+ chatId: "123",
312
+ phase: "thinking",
313
+ });
292
314
 
293
315
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendMessage", {
294
316
  chat_id: "123",
@@ -300,3 +322,78 @@ describe("telegramTransport topic targeting", () => {
300
322
  });
301
323
  });
302
324
  });
325
+
326
+ describe("editTelegramMessage", () => {
327
+ const ctx = { callbackUrl: "http://gw/deliver/telegram", params: {} };
328
+
329
+ const sendMessageCalls = () =>
330
+ callTelegramBotApiMock.mock.calls.filter(
331
+ (call) => call[0] === "sendMessage",
332
+ );
333
+
334
+ test("edits in place and never posts a new message", async () => {
335
+ await telegramTransport.edit!(ctx as CallbackContext, {
336
+ chatId: "123",
337
+ messageId: "456",
338
+ text: "revised",
339
+ });
340
+
341
+ expect(callTelegramBotApiMock).toHaveBeenCalledTimes(1);
342
+ const [method, body] = callTelegramBotApiMock.mock.calls[0]!;
343
+ expect(method).toBe("editMessageText");
344
+ // Telegram wants a numeric message id, where the capability carries the
345
+ // channel's id as a string.
346
+ expect(body).toEqual({
347
+ chat_id: "123",
348
+ message_id: 456,
349
+ text: "revised",
350
+ reply_markup: { inline_keyboard: [] },
351
+ });
352
+ });
353
+
354
+ test("clears the inline keyboard, so a settled message keeps no buttons", async () => {
355
+ await telegramTransport.edit!(ctx as CallbackContext, {
356
+ chatId: "123",
357
+ messageId: "456",
358
+ text: "\u2713 Approved",
359
+ });
360
+
361
+ const [, body] = callTelegramBotApiMock.mock.calls[0]!;
362
+ // Omitting reply_markup leaves an existing keyboard in place, which would
363
+ // leave live Approve and Reject buttons under text saying the request is
364
+ // already decided. The field has to be sent, and sent empty.
365
+ expect((body as Record<string, unknown>).reply_markup).toEqual({
366
+ inline_keyboard: [],
367
+ });
368
+ });
369
+
370
+ test("treats an unchanged message as already done", async () => {
371
+ callTelegramBotApiMock.mockImplementationOnce(async () => {
372
+ throw new TelegramNonRetryableError(
373
+ "Bad Request",
374
+ "Bad Request: message is not modified",
375
+ );
376
+ });
377
+
378
+ // The edit asked for a state the message is already in, which is the
379
+ // request satisfied rather than refused.
380
+ await expect(
381
+ editTelegramMessage("123", "456", "same"),
382
+ ).resolves.toBeUndefined();
383
+ expect(sendMessageCalls()).toHaveLength(0);
384
+ });
385
+
386
+ test("throws on any other rejection rather than posting a replacement", async () => {
387
+ callTelegramBotApiMock.mockImplementationOnce(async () => {
388
+ throw new TelegramNonRetryableError(
389
+ "Bad Request",
390
+ "Bad Request: message to edit not found",
391
+ );
392
+ });
393
+
394
+ // Posting instead would leave the original beside a duplicate, so the
395
+ // failure has to reach the caller.
396
+ await expect(editTelegramMessage("123", "456", "gone")).rejects.toThrow();
397
+ expect(sendMessageCalls()).toHaveLength(0);
398
+ });
399
+ });
@@ -137,6 +137,49 @@ export interface TelegramSendResult {
137
137
  * Send a Telegram text reply, splitting long messages and optionally
138
138
  * attaching inline keyboard buttons for approval prompts.
139
139
  */
140
+ /**
141
+ * Replace a Telegram message in place.
142
+ *
143
+ * Telegram rejects an edit whose text already matches the message. That is the
144
+ * request having been satisfied rather than a failure, so it resolves. Every
145
+ * other rejection throws: an edit that quietly became a new message would
146
+ * leave the original sitting beside it, which reads as answering twice.
147
+ *
148
+ * Unlike a send, this cannot split long text across messages, because an edit
149
+ * addresses exactly one. Telegram rejects text past its limit, and that
150
+ * rejection reaches the caller rather than being papered over.
151
+ *
152
+ * The empty `reply_markup` is load-bearing. `editMessageText` leaves an
153
+ * existing inline keyboard alone when the field is omitted, so a message
154
+ * revised to read as settled would keep its live buttons beside that text.
155
+ * Every caller here edits a message into a settled state, and the approval
156
+ * interception path says so outright, so the keyboard goes with the revision.
157
+ */
158
+ export async function editTelegramMessage(
159
+ chatId: string,
160
+ messageId: string,
161
+ text: string,
162
+ ): Promise<void> {
163
+ try {
164
+ await callTelegramBotApi<TelegramMessage>("editMessageText", {
165
+ chat_id: chatId,
166
+ message_id: Number(messageId),
167
+ text,
168
+ reply_markup: { inline_keyboard: [] },
169
+ });
170
+ } catch (err) {
171
+ if (
172
+ err instanceof TelegramNonRetryableError &&
173
+ err.description?.includes("message is not modified")
174
+ ) {
175
+ log.debug({ chatId, messageId }, "Telegram edit already applied");
176
+ return;
177
+ }
178
+ throw err;
179
+ }
180
+ log.debug({ chatId, messageId }, "Telegram message edited");
181
+ }
182
+
140
183
  export async function sendTelegramReply(
141
184
  chatId: string,
142
185
  text: string,
@@ -5,8 +5,10 @@ import type {
5
5
  CallbackContext,
6
6
  ChannelTransport,
7
7
  } from "../channel-transport.js";
8
+ import { isBusyActivityPhase } from "../channel-transport.js";
8
9
  import type { TelegramSendOptions } from "./send.js";
9
10
  import {
11
+ editTelegramMessage,
10
12
  sendTelegramAttachments,
11
13
  sendTelegramReply,
12
14
  sendTelegramRichReply,
@@ -27,16 +29,18 @@ function threadOptions(ctx: CallbackContext): TelegramSendOptions | undefined {
27
29
  export const telegramTransport: ChannelTransport = {
28
30
  channel: "telegram",
29
31
 
32
+ // Telegram clears a chat action after about five seconds.
33
+ activityRefreshMs: 4_000,
34
+
30
35
  async deliver(ctx, payload) {
31
36
  const { chatId, text, attachments, approval } = payload;
32
37
  const opts = threadOptions(ctx);
33
38
 
34
39
  if (text) {
35
- // `useBlocks` is the channel-neutral "render richly" intent set by the
36
- // delivery layer; the Telegram adapter honors it by forwarding markdown
37
- // to `sendRichMessage`, degrading to plain text otherwise (and on any
38
- // rich-send rejection).
39
- if (payload.useBlocks) {
40
+ // Telegram answers a rich render by forwarding markdown to
41
+ // `sendRichMessage`, degrading to plain text otherwise and on any
42
+ // rich-send rejection.
43
+ if (payload.renderRichly) {
40
44
  await sendTelegramRichReply(chatId, text, approval, opts);
41
45
  } else {
42
46
  await sendTelegramReply(chatId, text, approval, opts);
@@ -67,9 +71,22 @@ export const telegramTransport: ChannelTransport = {
67
71
  return { ok: true };
68
72
  },
69
73
 
70
- async typing(ctx, chatId) {
71
- await sendTelegramTypingIndicator(chatId, threadOptions(ctx));
72
- log.debug({ chatId }, "Telegram typing indicator delivered (direct)");
74
+ async edit(_ctx, target) {
75
+ await editTelegramMessage(target.chatId, target.messageId, target.text);
76
+ return { ok: true };
77
+ },
78
+
79
+ async setActivity(ctx, target) {
80
+ // Telegram's chat action expires by itself after a few seconds, so a phase
81
+ // that is not running needs no clearing call.
82
+ if (!isBusyActivityPhase(target.phase)) {
83
+ return { ok: true };
84
+ }
85
+ await sendTelegramTypingIndicator(target.chatId, threadOptions(ctx));
86
+ log.debug(
87
+ { chatId: target.chatId, phase: target.phase },
88
+ "Telegram typing indicator delivered (direct)",
89
+ );
73
90
  return { ok: true };
74
91
  },
75
92
  };
@@ -200,6 +200,17 @@ function makeMacOriginatedMessage(): MessageRow {
200
200
  });
201
201
  }
202
202
 
203
+ function makeWindowsOriginatedMessage(): MessageRow {
204
+ return makeMessage({
205
+ metadata: JSON.stringify({
206
+ userMessageChannel: "vellum",
207
+ userMessageInterface: "web",
208
+ client: { os: "windows" },
209
+ clientOsFromRequest: true,
210
+ }),
211
+ });
212
+ }
213
+
203
214
  /**
204
215
  * A turn opened from a plain browser tab, as opposed to the Electron desktop
205
216
  * renderer sharing the same web bundle: `client.os` is `"web"` only when
@@ -347,7 +358,7 @@ describe("emitAssistantReplyNotification", () => {
347
358
  desktopAttended = true;
348
359
  });
349
360
 
350
- test("marks the signal source-active while a Mac reports itself attended", async () => {
361
+ test("marks the signal source-active while a desktop reports itself attended", async () => {
351
362
  await run();
352
363
 
353
364
  expect(emitCalls).toHaveLength(1);
@@ -357,7 +368,16 @@ describe("emitAssistantReplyNotification", () => {
357
368
  expect(desktopPresenceArgs).toEqual([[]]);
358
369
  });
359
370
 
360
- test("leaves the signal live when no Mac is attended", async () => {
371
+ test("marks a Windows-originated signal source-active while attended", async () => {
372
+ initiatingRow = makeWindowsOriginatedMessage();
373
+
374
+ await run();
375
+
376
+ expect(emitCalls).toHaveLength(1);
377
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(true);
378
+ });
379
+
380
+ test("leaves the signal live when no desktop is attended", async () => {
361
381
  desktopAttended = false;
362
382
 
363
383
  await run();
@@ -386,10 +406,13 @@ describe("emitAssistantReplyNotification", () => {
386
406
  expect(warnCalls).toHaveLength(1);
387
407
  });
388
408
 
389
- // An attended Mac only speaks for a turn the Mac itself opened: the user
390
- // can send from the phone minutes after last touching the keyboard, which
409
+ // An attended desktop only speaks for a turn that desktop itself opened.
410
+ // The user can send from the phone minutes after last touching the keyboard,
391
411
  // is exactly the reply this producer exists to push.
392
- const NON_MAC_ORIGIN_CASES: Array<{ name: string; metadata: unknown }> = [
412
+ const NON_DESKTOP_ORIGIN_CASES: Array<{
413
+ name: string;
414
+ metadata: unknown;
415
+ }> = [
393
416
  {
394
417
  name: "the iOS app",
395
418
  metadata: { client: { os: "ios" }, clientOsFromRequest: true },
@@ -412,7 +435,7 @@ describe("emitAssistantReplyNotification", () => {
412
435
  // `macos` the conversation's live client state kept from an earlier
413
436
  // desktop send. Without the marker that OS names an earlier turn, not
414
437
  // this one, and the push the user is waiting for on their phone would
415
- // be dropped by the attended Mac.
438
+ // be dropped by the attended desktop.
416
439
  {
417
440
  name: "a row whose macOS OS was inherited, not reported",
418
441
  metadata: { userMessageInterface: "web", client: { os: "macos" } },
@@ -424,7 +447,7 @@ describe("emitAssistantReplyNotification", () => {
424
447
  },
425
448
  ];
426
449
 
427
- for (const { name, metadata } of NON_MAC_ORIGIN_CASES) {
450
+ for (const { name, metadata } of NON_DESKTOP_ORIGIN_CASES) {
428
451
  test(`leaves the signal live for a turn opened from ${name}`, async () => {
429
452
  initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
430
453
 
@@ -8,13 +8,18 @@
8
8
  * plain text with typed-command instructions.
9
9
  */
10
10
 
11
- import { sendTelegramReply } from "../../messaging/providers/telegram-bot/send.js";
11
+ import {
12
+ editTelegramMessage,
13
+ sendTelegramReply,
14
+ } from "../../messaging/providers/telegram-bot/send.js";
12
15
  import { ConfigError } from "../../util/errors.js";
13
16
  import { getLogger } from "../../util/logger.js";
14
17
  import type {
15
18
  ChannelAdapter,
16
19
  ChannelDeliveryPayload,
17
20
  ChannelDestination,
21
+ ChannelUpdateContext,
22
+ ChannelUpdatePayload,
18
23
  DeliveryResult,
19
24
  NotificationChannel,
20
25
  } from "../types.js";
@@ -97,4 +102,46 @@ export class TelegramAdapter implements ChannelAdapter {
97
102
  return { success: false, error: message };
98
103
  }
99
104
  }
105
+
106
+ /**
107
+ * Replace a delivered notification in place, so a card that has been
108
+ * answered or withdrawn stops reading as still waiting.
109
+ *
110
+ * `editTelegramMessage` clears the card's inline keyboard as part of the
111
+ * revision, so a card rewritten to read as settled cannot keep live Approve
112
+ * and Reject buttons beside that text.
113
+ */
114
+ async update(
115
+ delivery: ChannelUpdateContext,
116
+ patch: ChannelUpdatePayload,
117
+ ): Promise<DeliveryResult> {
118
+ if (!delivery.messageId) {
119
+ return {
120
+ success: false,
121
+ error:
122
+ "missing_message_id: this delivery has no captured Telegram message id",
123
+ };
124
+ }
125
+ const text = patch.body?.trim() || patch.title?.trim();
126
+ if (!text) {
127
+ return { success: false, error: "no body or title supplied for update" };
128
+ }
129
+ try {
130
+ await editTelegramMessage(delivery.destination, delivery.messageId, text);
131
+ log.info(
132
+ { chatId: delivery.destination, messageId: delivery.messageId },
133
+ "Telegram notification updated",
134
+ );
135
+ // An edit keeps the message it addressed, so the delivery row's id
136
+ // still identifies the card.
137
+ return { success: true, messageId: delivery.messageId };
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ log.error(
141
+ { err, chatId: delivery.destination, messageId: delivery.messageId },
142
+ "Failed to update Telegram notification",
143
+ );
144
+ return { success: false, error: message };
145
+ }
146
+ }
100
147
  }
@@ -23,7 +23,7 @@ import {
23
23
  parseMessageMetadata,
24
24
  } from "../persistence/conversation-crud.js";
25
25
  import {
26
- isMacOriginatedUserMessage,
26
+ isDesktopOriginatedUserMessage,
27
27
  isReplyPushIneligibleUserMessage,
28
28
  resolveConversationKind,
29
29
  } from "../persistence/conversation-types.js";
@@ -122,7 +122,7 @@ function readSuppressionMarkers(
122
122
  *
123
123
  * No `actorPrincipalId`: the platform delivers this push to the assistant
124
124
  * owner's device tokens, and a pod has exactly one owner, so any attended
125
- * macOS client is that owner's.
125
+ * desktop client is that owner's.
126
126
  */
127
127
  function readDesktopAttended(rlog: pino.Logger): boolean {
128
128
  try {
@@ -246,12 +246,12 @@ export async function emitAssistantReplyNotification(params: {
246
246
  );
247
247
 
248
248
  // Read as close to the emit as possible: nothing short-circuits on it.
249
- // Presence only speaks for a turn the Mac itself opened, on that row's own
250
- // OS evidence. A turn sent from the phone still needs its push while the
251
- // Mac sits idle within the desktop client's attendance window.
249
+ // Presence only speaks for a turn the desktop itself opened, on that row's
250
+ // own OS evidence. A turn sent from the phone still needs its push while the
251
+ // desktop sits idle within the attendance window.
252
252
  const desktopAttended =
253
253
  isAssistantFeatureFlagEnabled(DESKTOP_PRESENCE_FLAG) &&
254
- isMacOriginatedUserMessage(initiatingMetadata) &&
254
+ isDesktopOriginatedUserMessage(initiatingMetadata) &&
255
255
  readDesktopAttended(rlog);
256
256
 
257
257
  // Conversation-scoped web presence applies regardless of which device
@@ -276,7 +276,7 @@ export async function emitAssistantReplyNotification(params: {
276
276
  // opting into v2.
277
277
  urgency: "medium",
278
278
  isAsyncBackground: false,
279
- // Read weakly, as "at the surface this landed on": the attended Mac
279
+ // Read weakly, as "at the surface this landed on": the attended desktop
280
280
  // or focused web tab that opened the turn renders the reply in-app,
281
281
  // so a redundant push would only duplicate what's already on screen.
282
282
  visibleInSourceNow: desktopAttended || webFocused,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Generates richer seed content for notification conversations than the concise
5
5
  * title/body used in native notification popups. Verbosity adapts to the
6
- * delivery surface: vellum/macos gets flowing prose, telegram gets compact.
6
+ * delivery surface: Vellum desktop gets flowing prose, Telegram gets compact.
7
7
  *
8
8
  * Composes from `copy.title/body` rather than hardcoded English templates
9
9
  * so LLM-localized copy is preserved for non-English users.
@@ -25,7 +25,12 @@ const CHANNEL_DEFAULT_INTERFACE: Record<string, InterfaceId> = {
25
25
  telegram: "telegram",
26
26
  };
27
27
 
28
- const RICH_INTERFACES = new Set<InterfaceId>(["macos", "ios", "web"]);
28
+ const RICH_INTERFACES = new Set<InterfaceId>([
29
+ "macos",
30
+ "windows",
31
+ "ios",
32
+ "web",
33
+ ]);
29
34
 
30
35
  /**
31
36
  * Resolve verbosity level from delivery channel + optional interface hint.
@@ -358,6 +358,43 @@ describe("BYOOAuthConnection", () => {
358
358
  expect(headers.get("Content-Type")).toBe("application/json");
359
359
  });
360
360
 
361
+ test("sends a string body verbatim under the caller's Content-Type", async () => {
362
+ await setupCredential("google");
363
+ const conn = createConnection();
364
+ const multipart =
365
+ "--boundary\r\nContent-Type: application/json\r\n\r\n{}\r\n--boundary--\r\n";
366
+
367
+ await conn.request({
368
+ method: "POST",
369
+ path: "/upload/drive/v3/files",
370
+ headers: { "Content-Type": "multipart/related; boundary=boundary" },
371
+ body: multipart,
372
+ });
373
+
374
+ const [, init] = mockFetch.mock.calls[0];
375
+ expect((init as RequestInit).body).toBe(multipart);
376
+ const headers = (init as RequestInit).headers as Headers;
377
+ expect(headers.get("Content-Type")).toBe(
378
+ "multipart/related; boundary=boundary",
379
+ );
380
+ });
381
+
382
+ test("does not force a JSON Content-Type onto a string body", async () => {
383
+ await setupCredential("google");
384
+ const conn = createConnection();
385
+
386
+ await conn.request({
387
+ method: "POST",
388
+ path: "/upload",
389
+ body: "name=sheet&kind=grid",
390
+ });
391
+
392
+ const [, init] = mockFetch.mock.calls[0];
393
+ expect((init as RequestInit).body).toBe("name=sheet&kind=grid");
394
+ const headers = (init as RequestInit).headers as Headers;
395
+ expect(headers.has("Content-Type")).toBe(false);
396
+ });
397
+
361
398
  test("retries once on 401 response", async () => {
362
399
  await setupCredential("google");
363
400
  const conn = createConnection();
@@ -420,6 +457,32 @@ describe("BYOOAuthConnection", () => {
420
457
  expect(result.body).toBe("plain text response");
421
458
  });
422
459
 
460
+ test("preserves non-ASCII binary bytes for media downloads", async () => {
461
+ await setupCredential("google");
462
+ const conn = createConnection();
463
+ const binary = Buffer.from([
464
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
465
+ ]);
466
+
467
+ globalThis.fetch = mock(() =>
468
+ Promise.resolve(
469
+ new Response(binary, {
470
+ status: 200,
471
+ headers: { "content-type": "application/octet-stream" },
472
+ }),
473
+ ),
474
+ ) as unknown as typeof fetch;
475
+
476
+ const result = await conn.request({
477
+ method: "GET",
478
+ path: "/drive/v3/files/file-123?alt=media",
479
+ });
480
+
481
+ expect(result.status).toBe(200);
482
+ expect(Buffer.isBuffer(result.body)).toBe(true);
483
+ expect(Buffer.from(result.body as Uint8Array).equals(binary)).toBe(true);
484
+ });
485
+
423
486
  test("returns response headers", async () => {
424
487
  await setupCredential("google");
425
488
  const conn = createConnection();
@@ -14,6 +14,7 @@ import type {
14
14
  OAuthConnectionRequest,
15
15
  OAuthConnectionResponse,
16
16
  } from "./connection.js";
17
+ import { decodeOAuthResponseBytes } from "./connection.js";
17
18
 
18
19
  const log = getLogger("byo-oauth-connection");
19
20
 
@@ -80,10 +81,17 @@ export class BYOOAuthConnection implements OAuthConnection {
80
81
  "Making authenticated request",
81
82
  );
82
83
 
84
+ // A string body is already in its wire form (multipart, XML,
85
+ // form-encoded, or pre-serialized JSON) and travels verbatim under
86
+ // the caller's own Content-Type. Objects and arrays are serialized
87
+ // as JSON and get the JSON Content-Type by default.
88
+ const hasBody = req.body !== undefined && req.body !== null;
89
+ const rawBody = typeof req.body === "string" ? req.body : undefined;
90
+
83
91
  // Use the Headers API for case-insensitive merging. Set defaults
84
92
  // first so caller-supplied headers (in any casing) override them.
85
93
  const headers = new Headers();
86
- if (req.body) {
94
+ if (hasBody && rawBody === undefined) {
87
95
  headers.set("Content-Type", "application/json");
88
96
  }
89
97
  if (req.headers) {
@@ -98,7 +106,7 @@ export class BYOOAuthConnection implements OAuthConnection {
98
106
  const resp = await fetch(fullUrl, {
99
107
  method: req.method,
100
108
  headers,
101
- body: req.body ? JSON.stringify(req.body) : undefined,
109
+ body: hasBody ? (rawBody ?? JSON.stringify(req.body)) : undefined,
102
110
  signal: req.signal
103
111
  ? AbortSignal.any([
104
112
  req.signal,
@@ -150,17 +158,10 @@ async function buildResponse(resp: Response): Promise<OAuthConnectionResponse> {
150
158
  headers[key] = value;
151
159
  });
152
160
 
153
- let body: unknown;
154
- const text = await resp.text().catch(() => "");
155
- if (text) {
156
- try {
157
- body = JSON.parse(text);
158
- } catch {
159
- body = text;
160
- }
161
- } else {
162
- body = null;
163
- }
164
-
165
- return { status: resp.status, headers, body };
161
+ const raw = Buffer.from(await resp.arrayBuffer());
162
+ return {
163
+ status: resp.status,
164
+ headers,
165
+ body: decodeOAuthResponseBytes(raw, headers["content-type"] ?? ""),
166
+ };
166
167
  }