@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
@@ -570,14 +570,15 @@ describe("decision tool title field specification", () => {
570
570
  tool.input_schema.properties.renderedCopy.properties.vellum.properties
571
571
  .title.description;
572
572
 
573
- expect(description).toContain("2 to 5 words");
573
+ expect(description).toContain("2 to 6 words");
574
574
  expect(description).toContain("40 characters");
575
- expect(description).toContain("noun phrase");
576
- expect(description).toContain(
577
- "Do NOT restate, summarize, or echo the body",
578
- );
575
+ // The title carries a bell row on its own, so it states the outcome
576
+ // rather than naming a topic the body then explains.
577
+ expect(description).toContain("WHAT HAPPENED or WHAT IS NEEDED");
578
+ expect(description).toContain("Lead with the outcome");
579
+ expect(description).toContain("Do NOT restate the body word for word");
579
580
  expect(description).toContain("no markdown");
580
- expect(description).toContain("Missing Context");
581
+ expect(description).toContain("Missing context");
581
582
  expect(description.match(/NOT '/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
582
583
  });
583
584
  });
@@ -663,3 +664,75 @@ describe("title normalization", () => {
663
664
  expect(decision.renderedCopy.vellum?.title).toBe("Backup finished.");
664
665
  });
665
666
  });
667
+
668
+ function makeScheduleResultSignal(
669
+ overrides?: Partial<NotificationSignal>,
670
+ ): NotificationSignal {
671
+ return {
672
+ signalId: "sig-schedule-result-test-1",
673
+ createdAt: Date.now(),
674
+ sourceChannel: "scheduler",
675
+ sourceContextId: "conv-schedule-run",
676
+ sourceEventName: "schedule.result",
677
+ contextPayload: {
678
+ requestedMessage: "- **3 new emails**\n- `deploy.sh` failed overnight",
679
+ requestedTitle: "Morning briefing",
680
+ scheduleName: "Morning briefing",
681
+ scheduleId: "sched-1",
682
+ },
683
+ attentionHints: {
684
+ requiresAction: false,
685
+ urgency: "medium",
686
+ isAsyncBackground: true,
687
+ visibleInSourceNow: false,
688
+ },
689
+ ...overrides,
690
+ };
691
+ }
692
+
693
+ describe("schedule.result pass-through in notification decision engine", () => {
694
+ beforeEach(() => {
695
+ persistedDecisions = [];
696
+ });
697
+
698
+ test("carries the run's own output verbatim rather than rewriting it", async () => {
699
+ // The whole value of this notification is the digest itself. The
700
+ // classifier compresses a signal into a short alert, which would throw
701
+ // away the content the user set the schedule up to receive.
702
+ const decision = await evaluateSignal(makeScheduleResultSignal(), [
703
+ "vellum",
704
+ "platform",
705
+ ] as NotificationChannel[]);
706
+
707
+ expect(decision.renderedCopy.vellum?.body).toBe(
708
+ "- **3 new emails**\n- `deploy.sh` failed overnight",
709
+ );
710
+ expect(decision.renderedCopy.vellum?.title).toBe("Morning briefing");
711
+ expect(decision.reasoningSummary).toBe("schedule_result pass-through");
712
+ expect(decision.verbatimCopy).toBe(true);
713
+ expect(decision.fallbackUsed).toBe(false);
714
+ });
715
+
716
+ test("delivers to the notification center as well as push", async () => {
717
+ // Unlike an unseen chat reply, a scheduled run's output has no
718
+ // conversation the user is already in. `vellum` is where it persists.
719
+ const decision = await evaluateSignal(makeScheduleResultSignal(), [
720
+ "vellum",
721
+ "telegram",
722
+ "platform",
723
+ ] as NotificationChannel[]);
724
+
725
+ expect(decision.selectedChannels).toEqual(["vellum", "platform"]);
726
+ expect(decision.shouldNotify).toBe(true);
727
+ });
728
+
729
+ test("still reaches the inbox when push is unavailable", async () => {
730
+ const decision = await evaluateSignal(makeScheduleResultSignal(), [
731
+ "vellum",
732
+ "telegram",
733
+ ] as NotificationChannel[]);
734
+
735
+ expect(decision.selectedChannels).toEqual(["vellum"]);
736
+ expect(decision.shouldNotify).toBe(true);
737
+ });
738
+ });
@@ -919,6 +919,33 @@ describe("writeHomeFeedItemForSignal", () => {
919
919
  expect(appendCalls[0]!.noteworthy).toBe(true);
920
920
  });
921
921
 
922
+ test("a mapped source event carries its feed category", async () => {
923
+ conversationRow = { conversationType: "background" };
924
+ const signal = makeSignal({
925
+ contextPayload: { title: "Nightly briefing", body: "Three things." },
926
+ });
927
+
928
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
929
+
930
+ expect(item?.category).toBe("scheduling");
931
+ expect(appendCalls[0]!.category).toBe("scheduling");
932
+ });
933
+
934
+ test("an unmapped source event carries no category instead of falling back to system", async () => {
935
+ conversationRow = { conversationType: "background" };
936
+ const signal = makeSignal({
937
+ sourceChannel: "assistant_tool",
938
+ sourceEventName: "user.send_notification",
939
+ contextPayload: { title: "Tool share", body: "Body" },
940
+ });
941
+
942
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
943
+
944
+ expect(item).not.toBeNull();
945
+ expect("category" in item!).toBe(false);
946
+ expect("category" in appendCalls[0]!).toBe(false);
947
+ });
948
+
922
949
  test("credential.health_alert is noteworthy regardless of source channel", async () => {
923
950
  conversationRow = { conversationType: "background" };
924
951
  const signal = makeSignal({
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Tests for `schedule-result-producer.ts`: which finished schedule runs owe
3
+ * the user a notification, and the shape of the signal the qualifying ones
4
+ * emit.
5
+ *
6
+ * The two gates carry the design: a run that notified itself must stay silent
7
+ * (no duplicates for a well-authored schedule), and a run that said nothing
8
+ * must stay silent (no "your schedule ran" noise).
9
+ */
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
13
+ import type { AttentionState } from "../../persistence/conversation-attention-store.js";
14
+ import type { MessageRow } from "../../persistence/conversation-crud.js";
15
+ import type { ContentBlock } from "../../providers/types.js";
16
+
17
+ // ── Module mocks ───────────────────────────────────────────────────────
18
+ //
19
+ // `mock.module` is hoisted, so these intercepts apply before the module under
20
+ // test resolves its imports.
21
+
22
+ const emitCalls: any[] = [];
23
+ let assistantRow: MessageRow | null = null;
24
+ /** Earlier assistant rows of the same turn (tool-call steps before the reply). */
25
+ let turnRows: MessageRow[] = [];
26
+ let attentionState: AttentionState | null = null;
27
+ let alreadyNotified = false;
28
+ const notifiedProbeArgs: Array<[string, number]> = [];
29
+
30
+ mock.module("../emit-signal.js", () => ({
31
+ emitNotificationSignal: async (params: any) => {
32
+ emitCalls.push(params);
33
+ return {
34
+ signalId: "sig-1",
35
+ deduplicated: false,
36
+ dispatched: true,
37
+ reason: "ok",
38
+ deliveryResults: [],
39
+ };
40
+ },
41
+ }));
42
+
43
+ const realEventsStore = await import("../events-store.js");
44
+ mock.module("../events-store.js", () => ({
45
+ ...realEventsStore,
46
+ hasNotifiedSourceContextSince: (contextId: string, since: number) => {
47
+ notifiedProbeArgs.push([contextId, since]);
48
+ return alreadyNotified;
49
+ },
50
+ }));
51
+
52
+ const CONVERSATION_ID = "conv-sched-1";
53
+ const ASSISTANT_MESSAGE_ID = "msg-assistant-1";
54
+ const SCHEDULE_ID = "sched-1";
55
+ const RUN_ID = "run-1";
56
+ const RUN_STARTED_AT = 1700000000000;
57
+
58
+ const realCrud = await import("../../persistence/conversation-crud.js");
59
+ mock.module("../../persistence/conversation-crud.js", () => ({
60
+ ...realCrud,
61
+ getMessageById: (messageId: string) =>
62
+ messageId === ASSISTANT_MESSAGE_ID
63
+ ? assistantRow
64
+ : (turnRows.find((row) => row.id === messageId) ?? null),
65
+ getAssistantMessageIdsInTurn: (messageId: string) =>
66
+ messageId === ASSISTANT_MESSAGE_ID
67
+ ? [...turnRows.map((row) => row.id), ASSISTANT_MESSAGE_ID]
68
+ : [messageId],
69
+ }));
70
+
71
+ const realAttentionStore =
72
+ await import("../../persistence/conversation-attention-store.js");
73
+ mock.module("../../persistence/conversation-attention-store.js", () => ({
74
+ ...realAttentionStore,
75
+ getAttentionStateByConversationIds: (ids: string[]) => {
76
+ const map = new Map<string, AttentionState>();
77
+ if (attentionState) {
78
+ map.set(ids[0], attentionState);
79
+ }
80
+ return map;
81
+ },
82
+ }));
83
+
84
+ const { emitScheduleResultNotification } =
85
+ await import("../schedule-result-producer.js");
86
+
87
+ // ── Fixtures ───────────────────────────────────────────────────────────
88
+
89
+ function makeAssistantRow(
90
+ content: ContentBlock[],
91
+ overrides: Partial<MessageRow> = {},
92
+ ): MessageRow {
93
+ return {
94
+ id: ASSISTANT_MESSAGE_ID,
95
+ conversationId: CONVERSATION_ID,
96
+ role: "assistant",
97
+ content,
98
+ createdAt: RUN_STARTED_AT + 200,
99
+ metadata: null,
100
+ clientMessageId: null,
101
+ finalized: 1,
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ /** An earlier row of the run's turn holding a single tool call. */
107
+ function makeToolCallRow(
108
+ name: string,
109
+ input: Record<string, unknown>,
110
+ ): MessageRow {
111
+ return makeAssistantRow(
112
+ [{ type: "tool_use", id: `tu-${name}`, name, input }] as ContentBlock[],
113
+ { id: `msg-${name}`, createdAt: RUN_STARTED_AT + 100 },
114
+ );
115
+ }
116
+
117
+ function makeAttentionState(
118
+ overrides: Partial<AttentionState> = {},
119
+ ): AttentionState {
120
+ return {
121
+ conversationId: CONVERSATION_ID,
122
+ latestAssistantMessageId: ASSISTANT_MESSAGE_ID,
123
+ latestAssistantMessageAt: RUN_STARTED_AT + 200,
124
+ lastSeenAssistantMessageId: null,
125
+ lastSeenAssistantMessageAt: null,
126
+ lastSeenEventAt: null,
127
+ lastSeenConfidence: null,
128
+ lastSeenSignalType: null,
129
+ lastSeenSourceChannel: null,
130
+ lastSeenSource: null,
131
+ lastSeenEvidenceText: null,
132
+ createdAt: RUN_STARTED_AT,
133
+ updatedAt: RUN_STARTED_AT + 200,
134
+ ...overrides,
135
+ };
136
+ }
137
+
138
+ const warnCalls: unknown[] = [];
139
+ const infoCalls: unknown[] = [];
140
+ const rlog = {
141
+ warn: (...args: unknown[]) => {
142
+ warnCalls.push(args);
143
+ },
144
+ info: (...args: unknown[]) => {
145
+ infoCalls.push(args);
146
+ },
147
+ } as any;
148
+
149
+ async function run(
150
+ overrides: Partial<Parameters<typeof emitScheduleResultNotification>[0]> = {},
151
+ ): Promise<void> {
152
+ await emitScheduleResultNotification({
153
+ scheduleId: SCHEDULE_ID,
154
+ scheduleName: "Morning briefing",
155
+ conversationId: CONVERSATION_ID,
156
+ runId: RUN_ID,
157
+ runStartedAt: RUN_STARTED_AT,
158
+ rlog,
159
+ ...overrides,
160
+ });
161
+ }
162
+
163
+ beforeEach(() => {
164
+ emitCalls.length = 0;
165
+ warnCalls.length = 0;
166
+ infoCalls.length = 0;
167
+ notifiedProbeArgs.length = 0;
168
+ alreadyNotified = false;
169
+ turnRows = [];
170
+ attentionState = makeAttentionState();
171
+ assistantRow = makeAssistantRow([
172
+ { type: "text", text: "**3 new emails** and one calendar change." },
173
+ ] as ContentBlock[]);
174
+ setOverridesForTesting({ "schedule-result-notify": true });
175
+ });
176
+
177
+ describe("emitScheduleResultNotification", () => {
178
+ test("notifies when a run produced output and never notified itself", async () => {
179
+ await run();
180
+
181
+ expect(emitCalls).toHaveLength(1);
182
+ expect(emitCalls[0].sourceEventName).toBe("schedule.result");
183
+ expect(emitCalls[0].sourceChannel).toBe("scheduler");
184
+ // The run's conversation is the deep-link target and what earns the
185
+ // home-feed mirror.
186
+ expect(emitCalls[0].sourceContextId).toBe(CONVERSATION_ID);
187
+ expect(emitCalls[0].contextPayload.requestedTitle).toBe("Morning briefing");
188
+ expect(emitCalls[0].contextPayload.requestedMessage).toBe(
189
+ "**3 new emails** and one calendar change.",
190
+ );
191
+ });
192
+
193
+ test("stays silent when the run already notified itself", async () => {
194
+ alreadyNotified = true;
195
+
196
+ await run();
197
+
198
+ expect(emitCalls).toHaveLength(0);
199
+ });
200
+
201
+ test("bounds the already-notified probe to this run, not the conversation", async () => {
202
+ // A reused conversation carries every prior run's notifications. Without
203
+ // the lower bound, run #2 onward would be silenced by run #1's send.
204
+ await run();
205
+
206
+ expect(notifiedProbeArgs).toEqual([[CONVERSATION_ID, RUN_STARTED_AT]]);
207
+ });
208
+
209
+ test("stays silent when the run delivered by email through messaging_send", async () => {
210
+ // The schedule skill's prescribed route for rich content. It writes no
211
+ // notification event, so only the tool call shows the user has the result.
212
+ turnRows = [
213
+ makeToolCallRow("messaging_send", {
214
+ platform: "gmail",
215
+ conversation_id: "thread-1",
216
+ message: "Inbox digest…",
217
+ }),
218
+ ];
219
+ assistantRow = makeAssistantRow([
220
+ { type: "text", text: "Sent the digest to your inbox." },
221
+ ] as ContentBlock[]);
222
+
223
+ await run();
224
+
225
+ expect(emitCalls).toHaveLength(0);
226
+ });
227
+
228
+ test("stays silent when the run posted to Slack through chat.postMessage", async () => {
229
+ turnRows = [
230
+ makeToolCallRow("bash", {
231
+ command:
232
+ 'assistant oauth request --provider slack_channel /chat.postMessage --json \'{"channel":"C1","text":"digest"}\'',
233
+ }),
234
+ ];
235
+ assistantRow = makeAssistantRow([
236
+ { type: "text", text: "Posted the digest to #general." },
237
+ ] as ContentBlock[]);
238
+
239
+ await run();
240
+
241
+ expect(emitCalls).toHaveLength(0);
242
+ });
243
+
244
+ test("still notifies when the run's tool calls were not deliveries", async () => {
245
+ // Bash that reads Slack is not bash that posts to it.
246
+ turnRows = [
247
+ makeToolCallRow("bash", {
248
+ command:
249
+ 'assistant oauth request --provider slack_channel /conversations.history --json \'{"channel":"C1"}\'',
250
+ }),
251
+ makeToolCallRow("messaging_search", { query: "newer_than:1d" }),
252
+ ];
253
+
254
+ await run();
255
+
256
+ expect(emitCalls).toHaveLength(1);
257
+ });
258
+
259
+ test("stays silent when the latest reply predates this run", async () => {
260
+ // A reused conversation whose current run wrote no reply: the attention
261
+ // state still points at yesterday's, which must not be re-sent as today's.
262
+ assistantRow = makeAssistantRow(
263
+ [{ type: "text", text: "Yesterday's briefing." }] as ContentBlock[],
264
+ { createdAt: RUN_STARTED_AT - 1 },
265
+ );
266
+
267
+ await run();
268
+
269
+ expect(emitCalls).toHaveLength(0);
270
+ });
271
+
272
+ test("stays silent when the run produced no assistant message", async () => {
273
+ attentionState = makeAttentionState({ latestAssistantMessageId: null });
274
+
275
+ await run();
276
+
277
+ expect(emitCalls).toHaveLength(0);
278
+ });
279
+
280
+ test("stays silent when the reply flattens to nothing", async () => {
281
+ // A run that ended on tool calls with no closing prose: whitespace and
282
+ // markdown scaffolding only. Nothing to say, so nothing is said.
283
+ assistantRow = makeAssistantRow([
284
+ { type: "text", text: " \n\n \n" },
285
+ ] as ContentBlock[]);
286
+
287
+ await run();
288
+
289
+ expect(emitCalls).toHaveLength(0);
290
+ });
291
+
292
+ test("notifies for an honest empty-handed result", async () => {
293
+ // "Nothing changed" is the briefing's answer, not an absence of one. The
294
+ // user picked this cadence; a run with a real finding still reports.
295
+ assistantRow = makeAssistantRow([
296
+ { type: "text", text: "No new mail since yesterday." },
297
+ ] as ContentBlock[]);
298
+
299
+ await run();
300
+
301
+ expect(emitCalls).toHaveLength(1);
302
+ expect(emitCalls[0].contextPayload.requestedMessage).toBe(
303
+ "No new mail since yesterday.",
304
+ );
305
+ });
306
+
307
+ test("keeps markdown in the body so the detail panel renders it", async () => {
308
+ assistantRow = makeAssistantRow([
309
+ { type: "text", text: "- **PR #12** merged\n- `deploy.sh` failed" },
310
+ ] as ContentBlock[]);
311
+
312
+ await run();
313
+
314
+ expect(emitCalls[0].contextPayload.requestedMessage).toBe(
315
+ "- **PR #12** merged\n- `deploy.sh` failed",
316
+ );
317
+ });
318
+
319
+ test("emits at medium urgency so it neither posts silently nor alarms", async () => {
320
+ await run();
321
+
322
+ expect(emitCalls[0].attentionHints.urgency).toBe("medium");
323
+ expect(emitCalls[0].attentionHints.requiresAction).toBe(false);
324
+ expect(emitCalls[0].attentionHints.isAsyncBackground).toBe(true);
325
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(false);
326
+ });
327
+
328
+ test("keys dedupe on the run so sub-hourly schedules are not collapsed", async () => {
329
+ // The pipeline's dedupe window is a flat hour. A schedule-scoped key would
330
+ // silently drop every run after the first within that hour.
331
+ await run();
332
+
333
+ expect(emitCalls[0].dedupeKey).toBe(
334
+ `schedule.result:${SCHEDULE_ID}:${RUN_ID}`,
335
+ );
336
+ });
337
+
338
+ test("clamps an unbounded schedule name into a title", async () => {
339
+ await run({ scheduleName: "x".repeat(200) });
340
+
341
+ expect(
342
+ emitCalls[0].contextPayload.requestedTitle.length,
343
+ ).toBeLessThanOrEqual(60);
344
+ });
345
+
346
+ test("truncates a runaway reply rather than carrying it whole", async () => {
347
+ assistantRow = makeAssistantRow([
348
+ { type: "text", text: "y".repeat(5000) },
349
+ ] as ContentBlock[]);
350
+
351
+ await run();
352
+
353
+ expect(
354
+ emitCalls[0].contextPayload.requestedMessage.length,
355
+ ).toBeLessThanOrEqual(2000);
356
+ });
357
+
358
+ test("stays silent when the flag is off", async () => {
359
+ setOverridesForTesting({ "schedule-result-notify": false });
360
+
361
+ await run();
362
+
363
+ expect(emitCalls).toHaveLength(0);
364
+ });
365
+
366
+ test("stays silent for a bootstrap-failure sentinel conversation id", async () => {
367
+ await run({ conversationId: `bootstrap-error:${SCHEDULE_ID}` });
368
+
369
+ expect(emitCalls).toHaveLength(0);
370
+ });
371
+
372
+ test("swallows an emit failure so a successful run is not failed by it", async () => {
373
+ mock.module("../emit-signal.js", () => ({
374
+ emitNotificationSignal: async () => {
375
+ throw new Error("simulated pipeline failure");
376
+ },
377
+ }));
378
+ const { emitScheduleResultNotification: emitWithBrokenPipeline } =
379
+ await import("../schedule-result-producer.js");
380
+
381
+ await emitWithBrokenPipeline({
382
+ scheduleId: SCHEDULE_ID,
383
+ scheduleName: "Morning briefing",
384
+ conversationId: CONVERSATION_ID,
385
+ runId: RUN_ID,
386
+ runStartedAt: RUN_STARTED_AT,
387
+ rlog,
388
+ });
389
+
390
+ // Asserting the cause, not just that something warned: a re-import that
391
+ // failed for an unrelated reason would also land in the catch.
392
+ expect(warnCalls).toHaveLength(1);
393
+ expect((warnCalls[0] as any[])[0].err.message).toBe(
394
+ "simulated pipeline failure",
395
+ );
396
+ });
397
+ });
@@ -172,6 +172,15 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
172
172
  body: str(payload.message, "A reminder has fired"),
173
173
  }),
174
174
 
175
+ // Unreachable on the happy path: the producer always carries
176
+ // `requestedMessage`, so `composeFallbackCopy`'s verbatim branch answers
177
+ // first. This is the floor for a signal whose body was lost, and it names
178
+ // the schedule so the user can still find the run.
179
+ "schedule.result": (payload) => ({
180
+ title: sanitizedPayloadField(payload.scheduleName, "Schedule"),
181
+ body: "A scheduled run finished with results.",
182
+ }),
183
+
175
184
  // The schedule.* fields below (names, cadence, error reason) originate in
176
185
  // plugin-authored declaration files, so they are sanitized like any other
177
186
  // untrusted actor-controlled string before interpolation.
@@ -311,7 +320,8 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
311
320
  );
312
321
  const verb = parsed?.decision === "approved" ? "approved" : "denied";
313
322
  return {
314
- title: "Trusted Contact Decision",
323
+ // The outcome is the headline; who decided it is the body's to say.
324
+ title: `Access request ${verb}`,
315
325
  body: `${requesterLabel}'s access request has been ${verb} by ${decidedByLabel}.`,
316
326
  };
317
327
  },
@@ -326,15 +336,27 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
326
336
  body: str(payload.body, "A watcher event requires your attention"),
327
337
  }),
328
338
 
329
- "tool_confirmation.required_action": (payload) => ({
330
- title: "Tool Confirmation",
331
- body: str(payload.toolName, "A tool") + " requires your confirmation",
332
- }),
339
+ "tool_confirmation.required_action": (payload) => {
340
+ const toolName = str(payload.toolName, "A tool");
341
+ return {
342
+ title: `${toolName} needs your confirmation`,
343
+ body: `${toolName} requires your confirmation`,
344
+ };
345
+ },
333
346
 
334
- "activity.complete": (payload) => ({
335
- title: "Activity Complete",
336
- body: str(payload.summary, "An activity has completed"),
337
- }),
347
+ // Titled by what was done rather than by the kind of event: the summary's
348
+ // first sentence is the outcome ("Finished the fuel-system diagnostic app"),
349
+ // which is what a reader scanning the bell wants to see. A summary without
350
+ // one still gets a title that says something happened.
351
+ "activity.complete": (payload) => {
352
+ const summary = nonEmpty(
353
+ typeof payload.summary === "string" ? payload.summary : undefined,
354
+ );
355
+ return {
356
+ title: summary ? deriveTitle(summary) : "Activity complete",
357
+ body: summary ?? "An activity has completed",
358
+ };
359
+ },
338
360
 
339
361
  "activity.failed": (payload) => {
340
362
  const jobName = str(payload.jobName, "background job");
@@ -357,12 +379,12 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
357
379
  },
358
380
 
359
381
  "quick_chat.response_ready": (payload) => ({
360
- title: "Response Ready",
382
+ title: "Quick chat reply ready",
361
383
  body: str(payload.preview, "Your quick chat response is ready"),
362
384
  }),
363
385
 
364
386
  "voice.response_ready": (payload) => ({
365
- title: "Voice Response",
387
+ title: "Voice reply ready",
366
388
  body: str(payload.preview, "A voice response is ready"),
367
389
  }),
368
390
  };