@vellumai/assistant 0.11.10 → 0.11.11-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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -304,9 +304,11 @@ describe("formatModeSelectionFailure", () => {
304
304
  expect(formatted).toContain(
305
305
  "host browser proxy exists but is not connected",
306
306
  );
307
- expect(formatted).toContain("Remediation:");
308
- expect(formatted).toContain("extension is installed and enabled");
307
+ expect(formatted).toContain("Setup details:");
308
+ expect(formatted).toContain("Chrome extension");
309
309
  expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
310
+ expect(formatted).toContain("www.vellum.ai/download");
311
+ expect(formatted).toContain("Offer those first");
310
312
  });
311
313
 
312
314
  test("renders cdp-inspect transport_error with remediation", () => {
@@ -330,8 +332,9 @@ describe("formatModeSelectionFailure", () => {
330
332
  expect(formatted).toContain('Browser mode "cdp-inspect" failed');
331
333
  expect(formatted).toContain("cdp-inspect: FAILED at send");
332
334
  expect(formatted).toContain("Discovery code: unreachable");
333
- expect(formatted).toContain("Remediation:");
335
+ expect(formatted).toContain("Setup details:");
334
336
  expect(formatted).toContain("--remote-debugging-port");
337
+ expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
335
338
  });
336
339
 
337
340
  test("includes the Chrome Web Store install URL for host-bridge failures", () => {
@@ -353,8 +356,32 @@ describe("formatModeSelectionFailure", () => {
353
356
 
354
357
  const formatted = formatModeSelectionFailure("auto", error);
355
358
 
356
- expect(formatted).toContain("Remediation:");
359
+ expect(formatted).toContain("Setup details:");
360
+ expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
361
+ expect(formatted).toContain("www.vellum.ai/download");
362
+ });
363
+
364
+ test("phone surfaces do not invent an in-app browser panel", () => {
365
+ const diagnostics: AttemptDiagnostic[] = [
366
+ {
367
+ candidateKind: "extension",
368
+ inclusionReason: "pinned mode: extension",
369
+ stage: "candidate_selection",
370
+ errorCode: "transport_error",
371
+ errorMessage: "not connected",
372
+ },
373
+ ];
374
+ const formatted = formatModeSelectionFailure(
375
+ "extension",
376
+ new CdpError("transport_error", "not connected", {
377
+ attemptDiagnostics: diagnostics,
378
+ }),
379
+ { transportInterface: "ios", clientOs: "ios" },
380
+ );
381
+ expect(formatted).toContain("no in-app browser");
382
+ expect(formatted).toContain("Do not describe a browser panel");
357
383
  expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
384
+ expect(formatted).toContain("www.vellum.ai/download");
358
385
  });
359
386
  });
360
387
 
@@ -372,6 +399,7 @@ describe("browser_mode wiring through tool execution", () => {
372
399
  );
373
400
  expect(result.isError).toBe(true);
374
401
  expect(result.content).toContain('Invalid browser_mode "bogus"');
402
+ expect(result.content).not.toContain(CHROME_WEB_STORE_INSTALL_URL);
375
403
  expect(cdpSendCalls).toEqual([]);
376
404
  });
377
405
 
@@ -394,6 +422,28 @@ describe("browser_mode wiring through tool execution", () => {
394
422
 
395
423
  // ── Pinned extension with no proxy returns remediation error ───
396
424
 
425
+ test("navigation timeout offers the desktop app and Chrome extension", async () => {
426
+ cdpSendHandler = (method, params) => {
427
+ if (method === "Page.navigate") {
428
+ throw new Error(
429
+ "Navigation to https://intranet.example.com timed out after 15000ms",
430
+ );
431
+ }
432
+ return defaultCdpHandler(method, params);
433
+ };
434
+
435
+ const result = await executeBrowserNavigate(
436
+ { url: "https://intranet.example.com" },
437
+ ctx,
438
+ );
439
+ expect(result.isError).toBe(true);
440
+ expect(result.content).toContain("Navigation failed");
441
+ expect(result.content).toContain("timed out");
442
+ expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
443
+ expect(result.content).toContain("www.vellum.ai/download");
444
+ expect(result.content).toContain("Offer those first");
445
+ });
446
+
397
447
  test("pinned extension with no proxy returns remediation-rich error in navigate", async () => {
398
448
  factoryThrowError = new CdpError(
399
449
  "transport_error",
@@ -421,8 +471,8 @@ describe("browser_mode wiring through tool execution", () => {
421
471
  expect(result.content).toContain(
422
472
  "extension: FAILED at candidate_selection",
423
473
  );
424
- expect(result.content).toContain("Remediation:");
425
- expect(result.content).toContain("extension is installed and enabled");
474
+ expect(result.content).toContain("Setup details:");
475
+ expect(result.content).toContain("Chrome extension");
426
476
  expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
427
477
  // Factory should not have been called for CDP
428
478
  expect(cdpSendCalls).toEqual([]);
@@ -451,7 +501,7 @@ describe("browser_mode wiring through tool execution", () => {
451
501
  );
452
502
  expect(result.isError).toBe(true);
453
503
  expect(result.content).toContain('Browser mode "extension" failed');
454
- expect(result.content).toContain("Remediation:");
504
+ expect(result.content).toContain("Setup details:");
455
505
  });
456
506
 
457
507
  // ── cdp-debugger alias normalization ──────────────────────────
@@ -482,7 +532,7 @@ describe("browser_mode wiring through tool execution", () => {
482
532
  // The error should reference the canonical cdp-inspect name
483
533
  expect(result.content).toContain('Browser mode "cdp-inspect" failed');
484
534
  expect(result.content).toContain("Discovery code: unreachable");
485
- expect(result.content).toContain("Remediation:");
535
+ expect(result.content).toContain("Setup details:");
486
536
  expect(result.content).toContain("--remote-debugging-port");
487
537
  });
488
538
 
@@ -544,7 +594,7 @@ describe("browser_mode wiring through tool execution", () => {
544
594
  "Reason: HTTP discovery failed (unreachable)",
545
595
  );
546
596
  expect(result.content).toContain("Discovery code: unreachable");
547
- expect(result.content).toContain("Remediation:");
597
+ expect(result.content).toContain("Setup details:");
548
598
  });
549
599
 
550
600
  test("click tool returns remediation error on pinned mode failure", async () => {
@@ -576,7 +626,7 @@ describe("browser_mode wiring through tool execution", () => {
576
626
  expect(result.content).toContain(
577
627
  "extension: FAILED at candidate_selection",
578
628
  );
579
- expect(result.content).toContain("Remediation:");
629
+ expect(result.content).toContain("Setup details:");
580
630
  });
581
631
 
582
632
  // ── Transport-classified host-browser errors produce failover diagnostics ──
@@ -609,8 +659,8 @@ describe("browser_mode wiring through tool execution", () => {
609
659
  expect(result.content).toContain('Browser mode "extension" failed');
610
660
  expect(result.content).toContain("extension: FAILED at send");
611
661
  expect(result.content).toContain("Reason: Host browser not reachable");
612
- expect(result.content).toContain("Remediation:");
613
- expect(result.content).toContain("extension is installed and enabled");
662
+ expect(result.content).toContain("Setup details:");
663
+ expect(result.content).toContain("Chrome extension");
614
664
  expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
615
665
  });
616
666
 
@@ -634,7 +684,7 @@ describe("browser_mode wiring through tool execution", () => {
634
684
  expect(result.isError).toBe(true);
635
685
  expect(result.content).toContain('Browser mode "extension" failed');
636
686
  expect(result.content).toContain("extension: FAILED at send");
637
- expect(result.content).toContain("Remediation:");
687
+ expect(result.content).toContain("Setup details:");
638
688
  });
639
689
 
640
690
  // ── Per-conversation sticky backend kind ─────────────────────────
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
3
4
  import { setConfig } from "./helpers/set-config.js";
4
5
 
5
6
  // Default the warm-pool gate to OPEN — these tests probe disk-pressure
@@ -30,6 +31,7 @@ mock.module("../heartbeat/heartbeat-run-store.js", () => ({
30
31
  }));
31
32
 
32
33
  mock.module("../schedule/recurrence-engine.js", () => ({
34
+ hasSetConstructs,
33
35
  computeNextRunAt: () => Date.now() + 60_000,
34
36
  }));
35
37
 
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
6
+
5
7
  const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
6
8
 
7
9
  // Default the warm-pool gate to OPEN for existing tests — they predate
@@ -80,11 +82,14 @@ function setHeartbeatConfig(overrides: Partial<HeartbeatSeed> = {}): void {
80
82
  //
81
83
  // HeartbeatService imports computeNextRunAt for cron scheduling.
82
84
  // Tests mutate `mockComputeNextRunAt` to control the next cron occurrence.
85
+ // `hasSetConstructs` is re-exported because `schedule-timezone` imports it
86
+ // from the same module and must keep working under this mock.
83
87
  let mockComputeNextRunAtResult: number | null = null;
84
88
  let mockComputeNextRunAtError: Error | null = null;
85
89
  let computeNextRunAtCallCount = 0;
86
90
 
87
91
  mock.module("../schedule/recurrence-engine.js", () => ({
92
+ hasSetConstructs,
88
93
  computeNextRunAt: (_spec: {
89
94
  syntax: string;
90
95
  expression: string;
@@ -303,8 +308,11 @@ export function setTestProcessMessage(
303
308
  }
304
309
 
305
310
  // Import after mocks are set up
306
- const { HeartbeatService, isShallowProfile } =
307
- await import("../heartbeat/heartbeat-service.js");
311
+ const {
312
+ HeartbeatService,
313
+ isShallowProfile,
314
+ shouldRescheduleHeartbeatForTimezoneChange,
315
+ } = await import("../heartbeat/heartbeat-service.js");
308
316
 
309
317
  // Read the bundled template files so we can write them into the test workspace
310
318
  const templatesDir = join(import.meta.dirname!, "..", "prompts", "templates");
@@ -410,17 +418,20 @@ describe("HeartbeatService", () => {
410
418
 
411
419
  heartbeatConfig = defaultHeartbeatSeed();
412
420
  setHeartbeatConfig();
421
+ setConfig("ui", {});
413
422
  });
414
423
 
415
424
  function createService(overrides?: {
416
425
  processMessage?: (...args: unknown[]) => Promise<{ messageId: string }>;
417
426
  getCurrentHour?: () => number;
427
+ now?: () => Date;
418
428
  }) {
419
429
  if (overrides?.processMessage) {
420
430
  setTestProcessMessage(overrides.processMessage);
421
431
  }
422
432
  return new HeartbeatService({
423
433
  getCurrentHour: overrides?.getCurrentHour,
434
+ now: overrides?.now,
424
435
  });
425
436
  }
426
437
 
@@ -546,21 +557,52 @@ describe("HeartbeatService", () => {
546
557
  expect(processMessageCalls).toHaveLength(1);
547
558
  });
548
559
 
549
- test("active hours handles overnight window", async () => {
550
- setHeartbeatConfig({ activeHoursStart: 22, activeHoursEnd: 6 });
560
+ test("active hours handles overnight window", async () => {
561
+ setHeartbeatConfig({ activeHoursStart: 22, activeHoursEnd: 6 });
551
562
 
552
- // 23:00 should be within the window
553
- const service = createService({ getCurrentHour: () => 23 });
554
- await service.runOnce();
555
- expect(processMessageCalls).toHaveLength(1);
563
+ // 23:00 should be within the window
564
+ const service = createService({ getCurrentHour: () => 23 });
565
+ await service.runOnce();
566
+ expect(processMessageCalls).toHaveLength(1);
556
567
 
557
- // 10:00 should be outside the window
558
- processMessageCalls.length = 0;
559
- createdConversations.length = 0;
560
- const service2 = createService({ getCurrentHour: () => 10 });
561
- await service2.runOnce();
562
- expect(processMessageCalls).toHaveLength(0);
563
- });
568
+ // 10:00 should be outside the window
569
+ processMessageCalls.length = 0;
570
+ createdConversations.length = 0;
571
+ const service2 = createService({ getCurrentHour: () => 10 });
572
+ await service2.runOnce();
573
+ expect(processMessageCalls).toHaveLength(0);
574
+ });
575
+
576
+ test("active hours guard uses user timezone in interval mode", async () => {
577
+ setConfig("ui", { detectedTimezone: "America/Los_Angeles" });
578
+ setHeartbeatConfig({
579
+ cronExpression: null,
580
+ timezone: null,
581
+ activeHoursStart: 8,
582
+ activeHoursEnd: 22,
583
+ });
584
+
585
+ // 23:00 UTC is 16:00 Pacific (inside 8-22) and 23:00 host-local
586
+ // (outside 8-22). If the guard still used the host clock this would skip.
587
+ const inside = createService({
588
+ now: () => new Date("2026-09-08T23:00:00Z"),
589
+ getCurrentHour: () => 23,
590
+ });
591
+ expect(await inside.runOnce()).toBe(true);
592
+ expect(processMessageCalls).toHaveLength(1);
593
+
594
+ processMessageCalls.length = 0;
595
+ createdConversations.length = 0;
596
+
597
+ // 06:00 UTC is 23:00 Pacific, outside 8-22, even though 6 is inside
598
+ // a naive UTC 8-22 window.
599
+ const outside = createService({
600
+ now: () => new Date("2026-09-09T06:00:00Z"),
601
+ getCurrentHour: () => 6,
602
+ });
603
+ expect(await outside.runOnce()).toBe(false);
604
+ expect(processMessageCalls).toHaveLength(0);
605
+ });
564
606
 
565
607
  test("overlap prevention works", async () => {
566
608
  let resolveFirst: () => void;
@@ -1307,26 +1349,25 @@ describe("HeartbeatService", () => {
1307
1349
  test("active hours guard uses cron timezone when configured", async () => {
1308
1350
  setHeartbeatConfig({
1309
1351
  cronExpression: "0 9,12,15,18 * * *",
1310
- timezone: "UTC",
1311
- activeHoursStart: 9,
1312
- activeHoursEnd: 17,
1352
+ timezone: "America/Los_Angeles",
1353
+ activeHoursStart: 8,
1354
+ activeHoursEnd: 22,
1313
1355
  });
1314
1356
  mockComputeNextRunAtResult = Date.now() + 3_600_000;
1315
1357
 
1316
- const service = createService();
1358
+ // 23:00 UTC = 16:00 PDT, inside 8-22 Pacific.
1359
+ const service = createService({
1360
+ now: () => new Date("2026-09-08T23:00:00Z"),
1361
+ getCurrentHour: () => 23,
1362
+ });
1317
1363
  service.start();
1318
-
1319
- // In cron mode with timezone, the hour is computed via Intl.DateTimeFormat
1320
- // rather than getCurrentHour(). The test verifies the code path runs without
1321
- // error — the actual hour depends on the system clock and UTC conversion.
1322
- // We just verify it doesn't throw and returns a boolean result.
1323
1364
  const result = await service.runOnce();
1324
- // Result depends on current UTC hour vs active window — either outcome is valid
1325
- expect(typeof result).toBe("boolean");
1365
+ expect(result).toBe(true);
1366
+ expect(processMessageCalls).toHaveLength(1);
1326
1367
  service.stop();
1327
1368
  });
1328
1369
 
1329
- test("active hours guard falls back to getCurrentHour when cron mode has no timezone", async () => {
1370
+ test("active hours guard falls back to getCurrentHour when no timezone is known", async () => {
1330
1371
  setHeartbeatConfig({
1331
1372
  cronExpression: "0 9,12,15,18 * * *",
1332
1373
  timezone: null,
@@ -1705,3 +1746,53 @@ describe("HeartbeatService", () => {
1705
1746
  });
1706
1747
  });
1707
1748
  });
1749
+
1750
+ describe("shouldRescheduleHeartbeatForTimezoneChange", () => {
1751
+ test("reschedules cron heartbeats when the fallback user timezone changes", () => {
1752
+ expect(
1753
+ shouldRescheduleHeartbeatForTimezoneChange(
1754
+ {
1755
+ ui: { userTimezone: "America/New_York" },
1756
+ heartbeat: { cronExpression: "0 9 * * *" },
1757
+ },
1758
+ {
1759
+ ui: { userTimezone: "America/Los_Angeles" },
1760
+ heartbeat: { cronExpression: "0 9 * * *" },
1761
+ },
1762
+ ),
1763
+ ).toBe(true);
1764
+ });
1765
+
1766
+ test("ignores ui timezone changes in interval mode", () => {
1767
+ expect(
1768
+ shouldRescheduleHeartbeatForTimezoneChange(
1769
+ { ui: { userTimezone: "America/New_York" }, heartbeat: {} },
1770
+ {
1771
+ ui: { userTimezone: "America/Los_Angeles" },
1772
+ heartbeat: {},
1773
+ },
1774
+ ),
1775
+ ).toBe(false);
1776
+ });
1777
+
1778
+ test("ignores ui timezone changes when heartbeat.timezone is set", () => {
1779
+ expect(
1780
+ shouldRescheduleHeartbeatForTimezoneChange(
1781
+ {
1782
+ ui: { userTimezone: "America/New_York" },
1783
+ heartbeat: {
1784
+ cronExpression: "0 9 * * *",
1785
+ timezone: "UTC",
1786
+ },
1787
+ },
1788
+ {
1789
+ ui: { userTimezone: "America/Los_Angeles" },
1790
+ heartbeat: {
1791
+ cronExpression: "0 9 * * *",
1792
+ timezone: "UTC",
1793
+ },
1794
+ },
1795
+ ),
1796
+ ).toBe(false);
1797
+ });
1798
+ });
@@ -1398,7 +1398,7 @@ describe("HostAppControlProxy", () => {
1398
1398
  proxy.dispose();
1399
1399
  });
1400
1400
 
1401
- test("request() without targetClientId: does not register targetActorPrincipalId", async () => {
1401
+ test("request() without targetClientId: registers the source actor principal", async () => {
1402
1402
  const proxy = new HostAppControlProxy("conv-1");
1403
1403
  const ctrl = new AbortController();
1404
1404
 
@@ -1413,7 +1413,7 @@ describe("HostAppControlProxy", () => {
1413
1413
 
1414
1414
  const sent = sentMessages[0] as Record<string, unknown>;
1415
1415
  expect(registeredInteractions[0].targetClientId).toBeUndefined();
1416
- expect(registeredInteractions[0].targetActorPrincipalId).toBeUndefined();
1416
+ expect(registeredInteractions[0].targetActorPrincipalId).toBe("user-1");
1417
1417
 
1418
1418
  proxy.resolve(sent.requestId as string, payload({ pngBase64: PNG_A }));
1419
1419
  await resultPromise;
@@ -405,6 +405,47 @@ describe("handleHostAppControlResult — same-actor guard", () => {
405
405
  expect(resolveCalls).toHaveLength(1);
406
406
  });
407
407
 
408
+ test("non-targeted with recorded source actor: rejects a different principal", () => {
409
+ const requestId = "ac-req-untargeted-actor-mismatch";
410
+ const conversationId = "conv-untargeted-actor";
411
+ pending.set(requestId, {
412
+ conversationId,
413
+ kind: "host_app_control",
414
+ targetActorPrincipalId: "user-owner",
415
+ });
416
+ const resolveCalls: Array<{ requestId: string; payload: unknown }> = [];
417
+ setupConversation(conversationId, resolveCalls);
418
+
419
+ expect(() =>
420
+ handleHostAppControlResult({
421
+ body: { requestId, state: "running" },
422
+ headers: { "x-vellum-actor-principal-id": "user-attacker" },
423
+ }),
424
+ ).toThrow(ForbiddenError);
425
+ expect(resolveCalls).toHaveLength(0);
426
+ expect(pending.has(requestId)).toBe(true);
427
+ });
428
+
429
+ test("non-targeted with recorded source actor: accepts the matching principal", async () => {
430
+ const requestId = "ac-req-untargeted-actor-match";
431
+ const conversationId = "conv-untargeted-actor-match";
432
+ pending.set(requestId, {
433
+ conversationId,
434
+ kind: "host_app_control",
435
+ targetActorPrincipalId: "user-owner",
436
+ });
437
+ const resolveCalls: Array<{ requestId: string; payload: unknown }> = [];
438
+ setupConversation(conversationId, resolveCalls);
439
+
440
+ const result = await handleHostAppControlResult({
441
+ body: { requestId, state: "running" },
442
+ headers: { "x-vellum-actor-principal-id": "user-owner" },
443
+ });
444
+
445
+ expect(result).toEqual({ accepted: true });
446
+ expect(resolveCalls).toHaveLength(1);
447
+ });
448
+
408
449
  // ── Targeted + correct headers → 200 ─────────────────────────────────
409
450
 
410
451
  test("targeted + matching client id and actor principal: returns 200", async () => {
@@ -381,7 +381,7 @@ describe("handleHostBashResult", () => {
381
381
 
382
382
  // ── Untargeted-request behavior (regression for new check) ─────────
383
383
 
384
- describe("untargeted request — actor principal check is skipped", () => {
384
+ describe("untargeted request: source actor still binds when recorded", () => {
385
385
  test("accepts even when submitting actor is absent and no target client is set", async () => {
386
386
  const requestId = "req-untargeted-no-actor";
387
387
  registerPending(requestId);
@@ -395,6 +395,35 @@ describe("handleHostBashResult", () => {
395
395
  expect(resolveSpy).toHaveLength(1);
396
396
  expect(resolvedIds).toContain(requestId);
397
397
  });
398
+
399
+ test("rejects a different actor when the pending request recorded a source actor", () => {
400
+ const requestId = "req-untargeted-actor-mismatch";
401
+ registerPending(requestId, {
402
+ targetActorPrincipalId: "principal-owner",
403
+ });
404
+
405
+ expect(() =>
406
+ handleHostBashResult({
407
+ body: bashBody(requestId),
408
+ headers: { "x-vellum-actor-principal-id": "principal-attacker" },
409
+ }),
410
+ ).toThrow(ForbiddenError);
411
+ });
412
+
413
+ test("accepts the recorded source actor on an untargeted pending request", async () => {
414
+ const requestId = "req-untargeted-actor-match";
415
+ registerPending(requestId, {
416
+ targetActorPrincipalId: "principal-owner",
417
+ });
418
+
419
+ const result = await handleHostBashResult({
420
+ body: bashBody(requestId),
421
+ headers: { "x-vellum-actor-principal-id": "principal-owner" },
422
+ });
423
+
424
+ expect(result).toEqual({ accepted: true });
425
+ expect(resolveSpy).toHaveLength(1);
426
+ });
398
427
  });
399
428
 
400
429
  // ── Error: missing header on targeted request ──────────────────────
@@ -421,5 +421,39 @@ describe("handleHostBrowserResult — same-actor guard", () => {
421
421
  expect(result).toEqual({ accepted: true });
422
422
  expect(pendingInteractions.get(requestId)).toBeUndefined();
423
423
  });
424
+
425
+ test("rejects a different actor when the pending request recorded a source actor", () => {
426
+ const requestId = "browser-req-untargeted-actor-mismatch";
427
+ pendingInteractions.register(requestId, {
428
+ conversationId: "conv-1",
429
+ kind: "host_browser",
430
+ targetActorPrincipalId: "user-owner",
431
+ });
432
+
433
+ expect(() =>
434
+ handleHostBrowserResult({
435
+ body: { requestId, content: "ok", isError: false },
436
+ headers: { "x-vellum-actor-principal-id": "user-attacker" },
437
+ }),
438
+ ).toThrow(ForbiddenError);
439
+ expect(pendingInteractions.get(requestId)).toBeDefined();
440
+ });
441
+
442
+ test("accepts the recorded source actor on an untargeted pending request", async () => {
443
+ const requestId = "browser-req-untargeted-actor-match";
444
+ pendingInteractions.register(requestId, {
445
+ conversationId: "conv-1",
446
+ kind: "host_browser",
447
+ targetActorPrincipalId: "user-owner",
448
+ });
449
+
450
+ const result = await handleHostBrowserResult({
451
+ body: { requestId, content: "ok", isError: false },
452
+ headers: { "x-vellum-actor-principal-id": "user-owner" },
453
+ });
454
+
455
+ expect(result).toEqual({ accepted: true });
456
+ expect(pendingInteractions.get(requestId)).toBeUndefined();
457
+ });
424
458
  });
425
459
  });
@@ -417,6 +417,35 @@ describe("handleHostCuResult — Phase 2 targetClientId guard", () => {
417
417
  expect(result).toEqual({ accepted: true });
418
418
  expect(cuResolveSpy).toHaveLength(1);
419
419
  });
420
+
421
+ test("rejects a different actor when the pending request recorded a source actor", () => {
422
+ const requestId = "req-cu-untargeted-actor-mismatch";
423
+ registerPending(requestId, {
424
+ targetActorPrincipalId: "principal-owner",
425
+ });
426
+
427
+ expect(() =>
428
+ handleHostCuResult({
429
+ body: cuBody(requestId),
430
+ headers: { "x-vellum-actor-principal-id": "principal-attacker" },
431
+ }),
432
+ ).toThrow(ForbiddenError);
433
+ });
434
+
435
+ test("accepts the recorded source actor on an untargeted pending request", async () => {
436
+ const requestId = "req-cu-untargeted-actor-match";
437
+ registerPending(requestId, {
438
+ targetActorPrincipalId: "principal-owner",
439
+ });
440
+
441
+ const result = await handleHostCuResult({
442
+ body: cuBody(requestId),
443
+ headers: { "x-vellum-actor-principal-id": "principal-owner" },
444
+ });
445
+
446
+ expect(result).toEqual({ accepted: true });
447
+ expect(cuResolveSpy).toHaveLength(1);
448
+ });
420
449
  });
421
450
  });
422
451
 
@@ -279,6 +279,35 @@ describe("handleHostFileResult — targetClientId guard", () => {
279
279
  expect(result).toEqual({ accepted: true });
280
280
  expect(resolveSpy).toHaveLength(1);
281
281
  });
282
+
283
+ test("rejects a different actor when the pending request recorded a source actor", () => {
284
+ const requestId = "req-file-untargeted-actor-mismatch";
285
+ registerPending(requestId, {
286
+ targetActorPrincipalId: "principal-owner",
287
+ });
288
+
289
+ expect(() =>
290
+ handleHostFileResult({
291
+ body: fileBody(requestId),
292
+ headers: { "x-vellum-actor-principal-id": "principal-attacker" },
293
+ }),
294
+ ).toThrow(ForbiddenError);
295
+ });
296
+
297
+ test("accepts the recorded source actor on an untargeted pending request", async () => {
298
+ const requestId = "req-file-untargeted-actor-match";
299
+ registerPending(requestId, {
300
+ targetActorPrincipalId: "principal-owner",
301
+ });
302
+
303
+ const result = await handleHostFileResult({
304
+ body: fileBody(requestId),
305
+ headers: { "x-vellum-actor-principal-id": "principal-owner" },
306
+ });
307
+
308
+ expect(result).toEqual({ accepted: true });
309
+ expect(resolveSpy).toHaveLength(1);
310
+ });
282
311
  });
283
312
 
284
313
  // ── 5. Targeted + matching client but mismatched actor → 403 ──────────────
@@ -9,6 +9,7 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
9
9
  assistantEventHub: {
10
10
  getMostRecentClientByCapability: (cap: string) =>
11
11
  cap === "host_cu" && mockHasClient ? { id: "mock-client" } : null,
12
+ getActorPrincipalIdForClient: () => undefined,
12
13
  },
13
14
  }));
14
15