@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
@@ -29,6 +29,22 @@ mock.module("./channel-invite-transports/whatsapp.js", () => ({
29
29
  resolveWhatsAppDisplayNumber: () => undefined,
30
30
  }));
31
31
 
32
+ type SocketHealth = {
33
+ channel: string;
34
+ status: "connected" | "disconnected" | "not_configured" | "unsupported";
35
+ lastLivenessAt?: number;
36
+ };
37
+ let socketHealth: SocketHealth | Error;
38
+
39
+ mock.module("../channels/gateway-channel-socket-health.js", () => ({
40
+ readChannelSocketHealth: async () => {
41
+ if (socketHealth instanceof Error) {
42
+ throw socketHealth;
43
+ }
44
+ return socketHealth;
45
+ },
46
+ }));
47
+
32
48
  const originalFetch = globalThis.fetch;
33
49
  let fetchCalls: Array<{ url: string; init?: RequestInit }>;
34
50
  let fetchHandler: (
@@ -42,6 +58,7 @@ beforeEach(() => {
42
58
  setConfig("slack", {});
43
59
  fetchCalls = [];
44
60
  fetchHandler = () => ({ ok: true, body: { ok: true } });
61
+ socketHealth = { channel: "slack", status: "connected" };
45
62
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
46
63
  const url = typeof input === "string" ? input : input.toString();
47
64
  fetchCalls.push({ url, init });
@@ -295,3 +312,127 @@ describe("slack scope-grant check", () => {
295
312
  expect(scopeCheck.passed).toBe(true);
296
313
  });
297
314
  });
315
+
316
+ describe("slack inbound delivery", () => {
317
+ /**
318
+ * Credential health and delivery health are different claims. Valid tokens,
319
+ * a reachable `auth.test` and a matching workspace all hold while a socket
320
+ * sits open and delivers nothing, so the delivery verdict must not ride on
321
+ * any of them.
322
+ */
323
+ test("fails on a dead socket even while auth.test passes", async () => {
324
+ // Both tokens, or the channel is not fully configured and "incomplete" is
325
+ // the correct answer rather than the one under test.
326
+ mockSecureKeys[credentialKey("slack_channel", "bot_token")] = "xoxb-fake";
327
+ mockSecureKeys[credentialKey("slack_channel", "app_token")] = "xapp-fake";
328
+ setConfig("slack", { teamId: "T123" });
329
+ fetchHandler = () => ({
330
+ ok: true,
331
+ body: { ok: true, team_id: "T123", team: "acme", user: "apollobot" },
332
+ });
333
+ socketHealth = { channel: "slack", status: "disconnected" };
334
+
335
+ const [snapshot] = await runSlackRemoteProbe();
336
+ const authTest = (snapshot.remoteChecks ?? []).find(
337
+ (c) => c.name === "auth_test",
338
+ )!;
339
+ const delivery = snapshot.localChecks.find(
340
+ (c) => c.name === "inbound_delivery",
341
+ )!;
342
+
343
+ expect(authTest.passed).toBe(true);
344
+ expect(delivery.passed).toBe(false);
345
+ expect(delivery.indeterminate).toBeFalsy();
346
+ expect(delivery.message).toMatch(/not reaching this assistant/i);
347
+ // The point of declaring it operational: a configured channel whose
348
+ // socket is dead is down, not half set up.
349
+ expect(snapshot.setupStatus).toBe("ready");
350
+ expect(snapshot.health).toBe("failing");
351
+ expect(snapshot.ready).toBe(false);
352
+ });
353
+
354
+ test("passes on a live socket and reports the last keepalive", async () => {
355
+ socketHealth = {
356
+ channel: "slack",
357
+ status: "connected",
358
+ lastLivenessAt: Date.parse("2026-08-21T17:00:00.000Z"),
359
+ };
360
+
361
+ const [snapshot] = await runSlackRemoteProbe();
362
+ const delivery = snapshot.localChecks.find(
363
+ (c) => c.name === "inbound_delivery",
364
+ )!;
365
+
366
+ expect(delivery.passed).toBe(true);
367
+ expect(delivery.message).toContain("2026-08-21T17:00:00.000Z");
368
+ });
369
+
370
+ test("a freshly opened socket passes before its first keepalive", async () => {
371
+ // The first probe is a full interval after open, so requiring a keepalive
372
+ // would report every healthy reconnect as broken.
373
+ socketHealth = { channel: "slack", status: "connected" };
374
+
375
+ const [snapshot] = await runSlackRemoteProbe();
376
+ const delivery = snapshot.localChecks.find(
377
+ (c) => c.name === "inbound_delivery",
378
+ )!;
379
+
380
+ expect(delivery.passed).toBe(true);
381
+ expect(delivery.message).toMatch(/no keepalive answered yet/i);
382
+ });
383
+
384
+ test("an unconfigured channel is indeterminate, not broken", async () => {
385
+ socketHealth = { channel: "slack", status: "not_configured" };
386
+
387
+ const [snapshot] = await runSlackRemoteProbe();
388
+ const delivery = snapshot.localChecks.find(
389
+ (c) => c.name === "inbound_delivery",
390
+ )!;
391
+
392
+ expect(delivery.indeterminate).toBe(true);
393
+ expect(delivery.passed).toBe(true);
394
+ });
395
+
396
+ test("an unreachable gateway is indeterminate, not an outage", async () => {
397
+ // A gateway we cannot reach is a fact about the gateway. Rendering it as
398
+ // "Slack is disconnected" would invent an outage.
399
+ socketHealth = new Error("gateway socket refused");
400
+
401
+ const [snapshot] = await runSlackRemoteProbe();
402
+ const delivery = snapshot.localChecks.find(
403
+ (c) => c.name === "inbound_delivery",
404
+ )!;
405
+
406
+ expect(delivery.indeterminate).toBe(true);
407
+ expect(delivery.passed).toBe(true);
408
+ expect(delivery.message).toMatch(/could not reach the gateway/i);
409
+ // Unknown, not failing: an unreachable gateway is a fact about the
410
+ // gateway, and the channel must be neither vouched for nor condemned.
411
+ expect(snapshot.health).toBe("unknown");
412
+ expect(snapshot.ready).toBe(false);
413
+ });
414
+
415
+ test("runs on every request rather than riding the remote-check cache", async () => {
416
+ // Remote checks are cached for five minutes, which is far longer than a
417
+ // socket takes to die and recover, so a cached verdict would report a
418
+ // state the connection has already left. One service instance across both
419
+ // reads, or the cache under test is never populated in the first place.
420
+ const { createReadinessService } =
421
+ await import("../runtime/channel-readiness-service.js");
422
+ const service = createReadinessService();
423
+ const delivery = (
424
+ snapshots: Awaited<ReturnType<typeof service.getReadiness>>,
425
+ ) => snapshots[0].localChecks.find((c) => c.name === "inbound_delivery")!;
426
+
427
+ socketHealth = { channel: "slack", status: "connected" };
428
+ expect(delivery(await service.getReadiness("slack", true)).passed).toBe(
429
+ true,
430
+ );
431
+
432
+ // Same service, so the remote checks beside this one are now cached.
433
+ socketHealth = { channel: "slack", status: "disconnected" };
434
+ expect(delivery(await service.getReadiness("slack", true)).passed).toBe(
435
+ false,
436
+ );
437
+ });
438
+ });
@@ -266,7 +266,7 @@ describe("channel-reply-delivery", () => {
266
266
  payload: {
267
267
  chatId: "chat-1",
268
268
  text: "Before tool.",
269
- useBlocks: true,
269
+ renderRichly: true,
270
270
  attachments: undefined,
271
271
  assistantId: "assistant-1",
272
272
  },
@@ -276,7 +276,7 @@ describe("channel-reply-delivery", () => {
276
276
  payload: {
277
277
  chatId: "chat-1",
278
278
  text: "After tool.",
279
- useBlocks: true,
279
+ renderRichly: true,
280
280
  attachments,
281
281
  assistantId: "assistant-1",
282
282
  },
@@ -335,14 +335,14 @@ describe("channel-reply-delivery", () => {
335
335
  expect(deliveryCalls[0].payload).toEqual({
336
336
  chatId: "chat-3",
337
337
  text: "Before tool.",
338
- useBlocks: true,
338
+ renderRichly: true,
339
339
  attachments: undefined,
340
340
  assistantId: "assistant-2",
341
341
  });
342
342
  expect(deliveryCalls[1].payload).toEqual({
343
343
  chatId: "chat-3",
344
344
  text: "After tool.",
345
- useBlocks: true,
345
+ renderRichly: true,
346
346
  attachments: [
347
347
  {
348
348
  id: "att-2",
@@ -65,7 +65,7 @@ import type {
65
65
  import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
66
66
  import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
67
67
  import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
68
- import { isMacOriginatedUserMessage } from "../persistence/conversation-types.js";
68
+ import { isDesktopOriginatedUserMessage } from "../persistence/conversation-types.js";
69
69
 
70
70
  function createWebTurnContext(
71
71
  clientOs: string | undefined,
@@ -179,9 +179,9 @@ describe("client OS surface metadata persistence", () => {
179
179
  * `Conversation.clientOs` is a live field that only a transport-carrying
180
180
  * message refreshes, so a transport-less turn stamps whatever an earlier send
181
181
  * left there. `clientOsFromRequest` separates the two, and
182
- * {@link isMacOriginatedUserMessage} (the row-origin read behind the
182
+ * {@link isDesktopOriginatedUserMessage} (the row-origin read behind the
183
183
  * `chat.assistant_reply` presence gate) requires it before letting an attended
184
- * Mac suppress a push.
184
+ * desktop suppress a push.
185
185
  */
186
186
  describe("client OS per-row evidence marker", () => {
187
187
  beforeEach(() => {
@@ -198,7 +198,20 @@ describe("client OS per-row evidence marker", () => {
198
198
 
199
199
  expect(lastUserMetadata().client).toEqual({ os: "macos" });
200
200
  expect(lastUserMetadata().clientOsFromRequest).toBe(true);
201
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
201
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
202
+ });
203
+
204
+ test("recognizes a Windows app turn as desktop-originated", async () => {
205
+ const ctx = createWebTurnContext("windows");
206
+ await persistQueuedMessageBody(ctx, {
207
+ content: "hello",
208
+ requestId: "req-transport-os-windows",
209
+ requestClientOs: "windows",
210
+ });
211
+
212
+ expect(lastUserMetadata().client).toEqual({ os: "windows" });
213
+ expect(lastUserMetadata().clientOsFromRequest).toBe(true);
214
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
202
215
  });
203
216
 
204
217
  // Mirrors the Mac-originated case above: a plain browser tab reports
@@ -214,7 +227,7 @@ describe("client OS per-row evidence marker", () => {
214
227
 
215
228
  expect(lastUserMetadata().client).toEqual({ os: "web" });
216
229
  expect(lastUserMetadata().clientOsFromRequest).toBe(true);
217
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
230
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
218
231
  });
219
232
 
220
233
  test("marks an OS this row's own request headers reported", async () => {
@@ -226,14 +239,14 @@ describe("client OS per-row evidence marker", () => {
226
239
  });
227
240
 
228
241
  expect(lastUserMetadata().clientOsFromRequest).toBe(true);
229
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
242
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
230
243
  });
231
244
 
232
245
  // The bug shape: a button tapped on the phone reaches a conversation whose
233
246
  // last desktop send left `clientOs: "macos"` behind. The row still carries
234
247
  // the inherited OS for telemetry, but nothing says this turn came from the
235
248
  // Mac, so the reply push must survive.
236
- test("leaves an inherited OS unmarked and not Mac-originated", async () => {
249
+ test("leaves an inherited OS unmarked and not desktop-originated", async () => {
237
250
  const ctx = createWebTurnContext("macos");
238
251
  await persistQueuedMessageBody(ctx, {
239
252
  content: "[User action on card surface: submit]",
@@ -242,7 +255,7 @@ describe("client OS per-row evidence marker", () => {
242
255
 
243
256
  expect(lastUserMetadata().client).toEqual({ os: "macos" });
244
257
  expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
245
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
258
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
246
259
  });
247
260
 
248
261
  // The marker vouches for the `client.os` that actually landed, not for
@@ -258,7 +271,7 @@ describe("client OS per-row evidence marker", () => {
258
271
 
259
272
  expect(lastUserMetadata().client).toEqual({ os: "macos" });
260
273
  expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
261
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
274
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
262
275
  });
263
276
 
264
277
  // The marker is derived from what this persist can see, so a bag value
@@ -273,7 +286,7 @@ describe("client OS per-row evidence marker", () => {
273
286
  });
274
287
 
275
288
  expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
276
- expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
289
+ expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
277
290
  });
278
291
 
279
292
  test("leaves the marker off for a reported OS outside the vocabulary", async () => {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * A watch session's timeline is the most sensitive thing a conversation owns:
3
+ * what the user narrated, and frames of their screen while they narrated it.
4
+ * Both live in the timeline row, so every path that removes a conversation has
5
+ * to take those rows with it.
6
+ *
7
+ * `clearAll` is the sharpest case. Its bulk deletes run in a sqlite3
8
+ * subprocess, so the daemon's event loop is free between them and an append
9
+ * can land mid-wipe. The timeline purge is the wipe's last statement, after
10
+ * `conversations` is emptied: an append that beat it is a row it deletes, and
11
+ * one that follows it has no conversation to key itself to.
12
+ */
13
+ import { describe, expect, mock, test } from "bun:test";
14
+
15
+ // Keep the rest of the module real; only the Qdrant collection drop is
16
+ // replaced, so `clearAll` does not reach for a lexical index that is not
17
+ // running here.
18
+ const actualLexical =
19
+ await import("../persistence/job-handlers/message-lexical.js");
20
+ mock.module("../persistence/job-handlers/message-lexical.js", () => ({
21
+ ...actualLexical,
22
+ clearMessagesLexicalIndex: async () => {},
23
+ }));
24
+
25
+ /**
26
+ * An append to run from inside the wipe, on the statement it is keyed to.
27
+ *
28
+ * Which interleaving a race test runs is otherwise up to whichever subprocess
29
+ * happens to be slow, and the interleaving that matters is a narrow one: the
30
+ * append has to write its row while `conversations` is still populated, so its
31
+ * own existence check has nothing to catch and only the wipe's ordering keeps
32
+ * the row from outliving the wipe.
33
+ */
34
+ let appendDuringWipe: { onStatement: string; run: () => void } | null = null;
35
+
36
+ // Every bulk delete in `clearAll` goes through `runAsyncSqlite`, which spawns
37
+ // a sqlite3 subprocess and yields the event loop. The wrapper runs the real
38
+ // statement and stands in for whatever else the daemon would have done while
39
+ // it was in flight.
40
+ const actualAsyncQuery = await import("../persistence/db-async-query.js");
41
+ const realRunAsyncSqlite = actualAsyncQuery.runAsyncSqlite;
42
+ mock.module("../persistence/db-async-query.js", () => ({
43
+ ...actualAsyncQuery,
44
+ runAsyncSqlite: async (
45
+ sql: string,
46
+ label: string,
47
+ options?: Parameters<typeof actualAsyncQuery.runAsyncSqlite>[2],
48
+ ) => {
49
+ const result = await realRunAsyncSqlite(sql, label, options);
50
+ if (appendDuringWipe && appendDuringWipe.onStatement === sql) {
51
+ const pending = appendDuringWipe;
52
+ appendDuringWipe = null;
53
+ pending.run();
54
+ }
55
+ return result;
56
+ },
57
+ }));
58
+
59
+ import {
60
+ clearAll,
61
+ createConversation,
62
+ deleteConversation,
63
+ deleteConversationGently,
64
+ } from "../persistence/conversation-crud.js";
65
+ import { getDb } from "../persistence/db-connection.js";
66
+ import { initializeDb } from "../persistence/db-init.js";
67
+ import { attachments } from "../persistence/schema/conversations.js";
68
+ import type { WatchAppendResult } from "../watch/watch-timeline.js";
69
+ import {
70
+ appendNarration,
71
+ appendObservation,
72
+ readWatchScreenshot,
73
+ renderWatchTimeline,
74
+ } from "../watch/watch-timeline.js";
75
+
76
+ await initializeDb();
77
+
78
+ /** A 1x1 JPEG, the shape the host hands over on every observe. */
79
+ const SCREENSHOT_BASE64 =
80
+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==";
81
+
82
+ interface WatchedConversation {
83
+ readonly conversationId: string;
84
+ readonly sessionId: string;
85
+ readonly entryId: string;
86
+ }
87
+
88
+ /** A conversation with one narration and one screenshot-carrying observation. */
89
+ function seedWatchedConversation(title: string): WatchedConversation {
90
+ const conversation = createConversation(title);
91
+ const sessionId = `watch-${conversation.id}`;
92
+
93
+ appendObservation(sessionId, {
94
+ conversationId: conversation.id,
95
+ atMs: 1_000,
96
+ observation: { axTree: "window Mail", screenshot: SCREENSHOT_BASE64 },
97
+ attachScreenshot: true,
98
+ });
99
+ appendNarration(sessionId, {
100
+ conversationId: conversation.id,
101
+ atMs: 2_000,
102
+ text: "this is how I file it",
103
+ });
104
+
105
+ const rendered = renderWatchTimeline(sessionId);
106
+ expect(rendered.totalEntries).toBe(2);
107
+ const entryId = rendered.screenshotEntryIds[0];
108
+ expect(entryId).toBeDefined();
109
+ expect(readWatchScreenshot(entryId as string)).not.toBeNull();
110
+
111
+ return { conversationId: conversation.id, sessionId, entryId: entryId ?? "" };
112
+ }
113
+
114
+ function expectPurged(watched: WatchedConversation): void {
115
+ expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
116
+ expect(readWatchScreenshot(watched.entryId)).toBeNull();
117
+ }
118
+
119
+ /** Every attachment row in the store. */
120
+ function attachmentCount(): number {
121
+ return getDb().select({ id: attachments.id }).from(attachments).all().length;
122
+ }
123
+
124
+ describe("deleteConversation purges the watch timeline", () => {
125
+ test("takes the entries and the frames they carry", () => {
126
+ const watched = seedWatchedConversation("watched-sync");
127
+ const other = seedWatchedConversation("watched-sync-other");
128
+
129
+ // A timeline keeps its screenshots in its own rows, so watching a session
130
+ // stages nothing in the attachment store for a delete to chase.
131
+ expect(attachmentCount()).toBe(0);
132
+
133
+ deleteConversation(watched.conversationId);
134
+
135
+ expectPurged(watched);
136
+ // Scoped to the conversation being deleted, not the whole store.
137
+ expect(renderWatchTimeline(other.sessionId).totalEntries).toBe(2);
138
+ expect(readWatchScreenshot(other.entryId)).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("deleteConversationGently purges the watch timeline", () => {
143
+ test("the off-loop path clears the same state as the synchronous one", async () => {
144
+ const watched = seedWatchedConversation("watched-gentle");
145
+
146
+ await deleteConversationGently(watched.conversationId);
147
+
148
+ expectPurged(watched);
149
+ });
150
+ });
151
+
152
+ describe("an append that lands after the delete", () => {
153
+ test("is refused and leaves no row", () => {
154
+ const watched = seedWatchedConversation("watched-late-append");
155
+
156
+ deleteConversation(watched.conversationId);
157
+ const result = appendObservation(watched.sessionId, {
158
+ conversationId: watched.conversationId,
159
+ atMs: 3_000,
160
+ observation: { axTree: "window Mail", screenshot: SCREENSHOT_BASE64 },
161
+ attachScreenshot: true,
162
+ });
163
+
164
+ expect(result).toEqual({ ok: false, reason: "conversation_missing" });
165
+ expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
166
+ });
167
+ });
168
+
169
+ describe("clearAll purges every watch timeline", () => {
170
+ test("wipes the entries and the frames they carry", async () => {
171
+ const first = seedWatchedConversation("watched-clear-all-a");
172
+ const second = seedWatchedConversation("watched-clear-all-b");
173
+
174
+ await clearAll();
175
+
176
+ expectPurged(first);
177
+ expectPurged(second);
178
+ });
179
+ });
180
+
181
+ describe("an append that lands inside a clear-all", () => {
182
+ test("does not outlive the wipe that overtook it", async () => {
183
+ const watched = seedWatchedConversation("watched-clear-all-race");
184
+
185
+ // The append runs where the wipe is most exposed: mid-sequence, with
186
+ // `conversations` still populated so its existence check has nothing to
187
+ // catch. Only the timeline purge coming after `conversations` keeps the
188
+ // row from surviving.
189
+ let landed: WatchAppendResult | null = null;
190
+ appendDuringWipe = {
191
+ onStatement: "DELETE FROM messages",
192
+ run: () => {
193
+ landed = appendObservation(watched.sessionId, {
194
+ conversationId: watched.conversationId,
195
+ atMs: 3_000,
196
+ observation: {
197
+ axTree: "window Mail",
198
+ screenshot: SCREENSHOT_BASE64,
199
+ },
200
+ attachScreenshot: true,
201
+ });
202
+ },
203
+ };
204
+
205
+ await clearAll();
206
+
207
+ // The row really did land, so the wipe had something to sweep.
208
+ const result = landed as WatchAppendResult | null;
209
+ expect(result?.ok).toBe(true);
210
+ expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
211
+ expect(
212
+ readWatchScreenshot(result?.ok === true ? result.entryId : ""),
213
+ ).toBeNull();
214
+ });
215
+ });
216
+
217
+ describe("an append that arrives once the clear-all is done", () => {
218
+ test("is refused, because there is no conversation left to key it to", async () => {
219
+ const watched = seedWatchedConversation("watched-clear-all-after");
220
+
221
+ await clearAll();
222
+ const result = appendNarration(watched.sessionId, {
223
+ conversationId: watched.conversationId,
224
+ atMs: 3_000,
225
+ text: "too late",
226
+ });
227
+
228
+ expect(result).toEqual({ ok: false, reason: "conversation_missing" });
229
+ expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
230
+ });
231
+ });
@@ -582,6 +582,23 @@ describe("classifyConversationError", () => {
582
582
  expect(result.retryable).toBe(true);
583
583
  expect(result.errorCategory).toBe("tool_ordering");
584
584
  });
585
+
586
+ it("classifies user-terminal history rejections separately", () => {
587
+ /** User-terminal provider rejections receive a dedicated category. */
588
+
589
+ // GIVEN a provider error whose history ends with a model turn
590
+ const error = new Error(
591
+ "Requests ending with a model turn are not supported.",
592
+ );
593
+
594
+ // WHEN the conversation error is classified
595
+ const result = classifyConversationError(error, baseCtx);
596
+
597
+ // THEN it is a retryable ordering error in the user-terminal category
598
+ expect(result.code).toBe("PROVIDER_ORDERING");
599
+ expect(result.retryable).toBe(true);
600
+ expect(result.errorCategory).toBe("history_user_terminal");
601
+ });
585
602
  });
586
603
 
587
604
  describe("web search ordering errors", () => {
@@ -71,6 +71,14 @@ describe("resolveVerbosity", () => {
71
71
  ).toBe("rich");
72
72
  });
73
73
 
74
+ test("explicit interfaceHint=windows overrides to rich", () => {
75
+ expect(
76
+ resolveVerbosity("telegram" as NotificationChannel, {
77
+ interfaceHint: "windows",
78
+ }),
79
+ ).toBe("rich");
80
+ });
81
+
74
82
  test("explicit interfaceHint=telegram overrides to compact", () => {
75
83
  expect(
76
84
  resolveVerbosity("vellum" as NotificationChannel, {
@@ -49,6 +49,14 @@ describe("resolveSlash /commands interface-aware help", () => {
49
49
  ]);
50
50
  });
51
51
 
52
+ test("renders desktop command help for Windows", async () => {
53
+ const lines = await resolveCommandsLines(
54
+ makeSlashContext({ userMessageInterface: "windows" }),
55
+ );
56
+ expect(lines.some((line) => line.startsWith("/btw "))).toBe(true);
57
+ expect(lines.some((line) => line.startsWith("/fork "))).toBe(true);
58
+ });
59
+
52
60
  test("renders iOS command help with /fork", async () => {
53
61
  const lines = await resolveCommandsLines(
54
62
  makeSlashContext({ userMessageInterface: "ios" }),
@@ -90,6 +90,12 @@ describe("classifyDiskPressureTurnPolicy", () => {
90
90
  metadata: localOwnerTurn,
91
91
  expected: { action: "allow-cleanup-mode", reason: "local-owner" },
92
92
  },
93
+ {
94
+ name: "locked Windows local owner without trust enters cleanup mode",
95
+ status: status({ acknowledged: true }),
96
+ metadata: { ...localOwnerTurn, sourceInterface: "windows" },
97
+ expected: { action: "allow-cleanup-mode", reason: "local-owner" },
98
+ },
93
99
  {
94
100
  name: "locked guardian enters cleanup mode",
95
101
  status: status(),