@vellumai/assistant 0.4.35 → 0.4.37

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 (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Signature verification for .vellumapp archives.
2
+ * Signature verification for .vellum archives.
3
3
  *
4
4
  * Checks bundle integrity and Ed25519 signature validity.
5
5
  */
@@ -39,9 +39,9 @@ function sortKeysDeep(obj: unknown): unknown {
39
39
  }
40
40
 
41
41
  /**
42
- * Verify the signature and integrity of a .vellumapp bundle.
42
+ * Verify the signature and integrity of a .vellum bundle.
43
43
  *
44
- * @param zipPath - Path to the .vellumapp zip archive.
44
+ * @param zipPath - Path to the .vellum zip archive.
45
45
  * @param trustedPublicKeys - Optional map of keyId -> base64-encoded public key for verification.
46
46
  * If not provided, signature is checked structurally but returns 'signed' at best.
47
47
  * @returns The verification result with trust tier and signer info.
@@ -60,7 +60,7 @@ const CHANNEL_POLICIES = {
60
60
  conversationStrategy: "continue_existing_conversation",
61
61
  },
62
62
  invite: {
63
- codeRedemptionEnabled: false,
63
+ codeRedemptionEnabled: true,
64
64
  },
65
65
  },
66
66
  slack: {
@@ -0,0 +1,63 @@
1
+ # Assistant CLI — Agent Instructions
2
+
3
+ ## Purpose
4
+
5
+ Commands in `assistant/src/cli/` are scoped to a **single running assistant instance**. They operate on the assistant's local state — config, memory, contacts, trust rules, sessions, autonomy, etc. — and run within the context of the assistant's workspace.
6
+
7
+ This contrasts with `cli/`, which manages the **lifecycle of assistant instances** (create, start, stop, delete) and operates across instances. See `cli/AGENTS.md`.
8
+
9
+ ## When a command belongs here vs `cli/`
10
+
11
+ | `assistant/src/cli/` (this directory) | `cli/` |
12
+ | --------------------------------------------------- | ----------------------------------------------- |
13
+ | Operates within a single assistant's workspace | Operates on or across assistant instances |
14
+ | Manages instance-local state (config, memory, etc.) | Manages lifecycle (create, start, stop, delete) |
15
+ | Implicitly scoped to the running assistant | Requires specifying which assistant to target |
16
+ | May require or start the daemon | Works without an assistant process running |
17
+
18
+ Examples: `config`, `contacts`, `memory`, `autonomy`, `sessions`, `doctor` belong here. `hatch`, `wake`, `sleep`, `retire`, `ps`, `ssh` belong in `cli/`.
19
+
20
+ ## Conventions
21
+
22
+ - Commands use [Commander.js](https://github.com/tj/commander.js) and follow the `registerXCommand(program: Command)` pattern.
23
+ - Each command module exports a registration function that attaches subcommands to the program.
24
+ - Register new commands in `assistant/src/cli/program.ts` inside the `buildCliProgram()` function by importing and calling the registration function.
25
+ - Use `getCliLogger("cli")` for output (not raw `console.log`).
26
+
27
+ ## Service calls — no gateway proxying
28
+
29
+ CLI commands must call the service/store layer directly — the same functions that the HTTP route handlers in `runtime/routes/` call. Do not proxy through the gateway HTTP API.
30
+
31
+ Both the gateway routes and the CLI are thin wrappers around the same shared business logic. For example, `runtime/routes/invite-routes.ts` delegates to `runtime/invite-service.ts`, and `runtime/routes/contact-routes.ts` delegates to `contacts/contact-store.ts`. CLI commands should import and call those same service modules directly.
32
+
33
+ This avoids a dependency on the gateway process being running and removes an unnecessary network hop.
34
+
35
+ ## Help Text Standards
36
+
37
+ Every command at every level (namespace, subcommand, nested subcommand) must have
38
+ high-quality `--help` output optimized for AI/LLM consumption. Help text is a
39
+ primary interface — both humans and AI agents read it to understand what a command
40
+ does and how to use it.
41
+
42
+ ### Requirements
43
+
44
+ 1. **Top-level namespace**: Use `.description()` with a concise one-liner, then
45
+ `.addHelpText("after", ...)` with:
46
+ - A brief explanation of the domain and key concepts (e.g. naming conventions,
47
+ storage model)
48
+ - 3-4 representative examples covering the most common workflows
49
+
50
+ 2. **Each subcommand**: Use `.description()` with a one-liner, then
51
+ `.addHelpText("after", ...)` with:
52
+ - An `Arguments:` block explaining each positional argument with its format
53
+ and constraints
54
+ - Behavioral notes (what happens on update vs create, what gets deleted, etc.)
55
+ - 2-3 concrete `Examples:` showing exact invocations with realistic values
56
+
57
+ 3. **Write for machines**: Help text is frequently parsed by AI agents to decide
58
+ which command to run and how. Be precise about formats (`service:field`),
59
+ constraints (required vs optional), and side effects. Avoid vague language
60
+ like "configure settings" — say exactly what is configured and where it's stored.
61
+
62
+ 4. **Use Commander's `.addHelpText("after", ...)`** for extended help. Don't
63
+ cram everything into `.description()`.
@@ -0,0 +1,470 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ afterAll,
6
+ beforeAll,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ mock,
11
+ test,
12
+ } from "bun:test";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Test isolation: in-memory SQLite via temp directory
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), "cli-notifications-test-"));
19
+
20
+ mock.module("../../util/platform.js", () => ({
21
+ getRootDir: () => testDir,
22
+ getDataDir: () => testDir,
23
+ isMacOS: () => process.platform === "darwin",
24
+ isLinux: () => process.platform === "linux",
25
+ isWindows: () => process.platform === "win32",
26
+ getSocketPath: () => join(testDir, "test.sock"),
27
+ getPidPath: () => join(testDir, "test.pid"),
28
+ getDbPath: () => join(testDir, "test.db"),
29
+ getLogPath: () => join(testDir, "test.log"),
30
+ ensureDataDir: () => {},
31
+ }));
32
+
33
+ mock.module("../../util/logger.js", () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ getCliLogger: () =>
39
+ new Proxy({} as Record<string, unknown>, {
40
+ get: () => () => {},
41
+ }),
42
+ }));
43
+
44
+ // Track emitNotificationSignal calls
45
+ const emitSignalCalls: Array<Record<string, unknown>> = [];
46
+ mock.module("../../notifications/emit-signal.js", () => ({
47
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
48
+ emitSignalCalls.push(params);
49
+ return {
50
+ signalId: "mock-id",
51
+ deduplicated: false,
52
+ dispatched: true,
53
+ reason: "ok",
54
+ deliveryResults: [],
55
+ };
56
+ },
57
+ }));
58
+
59
+ mock.module("../../channels/config.js", () => ({
60
+ getDeliverableChannels: () => ["vellum", "telegram", "slack"],
61
+ getChannelPolicy: () => ({
62
+ notification: {
63
+ deliveryEnabled: true,
64
+ conversationStrategy: "start_new_conversation",
65
+ },
66
+ invite: { codeRedemptionEnabled: false },
67
+ }),
68
+ isNotificationDeliverable: () => true,
69
+ getConversationStrategy: () => "start_new_conversation",
70
+ getChannelInvitePolicy: () => ({ codeRedemptionEnabled: false }),
71
+ isInviteCodeRedemptionEnabled: () => false,
72
+ }));
73
+
74
+ import { Command } from "commander";
75
+
76
+ import { getDb, initializeDb, resetDb } from "../../memory/db.js";
77
+ import { createEvent } from "../../notifications/events-store.js";
78
+ import { registerNotificationsCommand } from "../notifications.js";
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ interface CommandResult {
85
+ parsed: Record<string, unknown>;
86
+ exitCode: number;
87
+ }
88
+
89
+ /**
90
+ * Run a notifications subcommand and capture the JSON output.
91
+ * Always passes --json to get compact, single-line JSON output and suppress log messages.
92
+ *
93
+ * Follows the same process.exitCode pattern as credential-cli.test.ts:
94
+ * reset to 0, capture, then reset back to 0 so bun test exits cleanly.
95
+ */
96
+ async function runCommand(args: string[]): Promise<CommandResult> {
97
+ const chunks: string[] = [];
98
+ const originalWrite = process.stdout.write;
99
+
100
+ process.exitCode = 0;
101
+
102
+ process.stdout.write = ((chunk: string | Buffer) => {
103
+ chunks.push(typeof chunk === "string" ? chunk : chunk.toString());
104
+ return true;
105
+ }) as typeof process.stdout.write;
106
+
107
+ try {
108
+ const program = new Command();
109
+ program.exitOverride();
110
+ registerNotificationsCommand(program);
111
+ await program.parseAsync([
112
+ "node",
113
+ "test",
114
+ "notifications",
115
+ "--json",
116
+ ...args,
117
+ ]);
118
+ } catch {
119
+ // Commander throws on .exitOverride() for --help/errors; ignore
120
+ } finally {
121
+ process.stdout.write = originalWrite;
122
+ }
123
+
124
+ const exitCode = process.exitCode ?? 0;
125
+ process.exitCode = 0;
126
+
127
+ const output = chunks.join("");
128
+ const firstLine = output.trim().split("\n")[0];
129
+ const parsed = firstLine
130
+ ? (JSON.parse(firstLine) as Record<string, unknown>)
131
+ : {};
132
+
133
+ return { parsed, exitCode };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Setup / teardown
138
+ // ---------------------------------------------------------------------------
139
+
140
+ beforeAll(() => {
141
+ initializeDb();
142
+ });
143
+
144
+ beforeEach(() => {
145
+ emitSignalCalls.length = 0;
146
+ process.exitCode = 0;
147
+ });
148
+
149
+ afterAll(() => {
150
+ resetDb();
151
+ try {
152
+ rmSync(testDir, { recursive: true });
153
+ } catch {
154
+ /* best effort */
155
+ }
156
+ process.exitCode = 0;
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // send subcommand
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("notifications send", () => {
164
+ test("send with valid args emits signal", async () => {
165
+ const { parsed, exitCode } = await runCommand([
166
+ "send",
167
+ "--source-channel",
168
+ "assistant_tool",
169
+ "--source-event-name",
170
+ "user.send_notification",
171
+ "--message",
172
+ "Hello",
173
+ ]);
174
+
175
+ expect(exitCode).toBe(0);
176
+ expect(parsed.ok).toBe(true);
177
+ expect(parsed.signalId).toBe("mock-id");
178
+
179
+ expect(emitSignalCalls).toHaveLength(1);
180
+ const call = emitSignalCalls[0];
181
+ expect(call.sourceChannel).toBe("assistant_tool");
182
+ expect(call.sourceEventName).toBe("user.send_notification");
183
+ const payload = call.contextPayload as Record<string, unknown>;
184
+ expect(payload.requestedMessage).toBe("Hello");
185
+ });
186
+
187
+ test("send passes urgency and attention hints", async () => {
188
+ const { parsed, exitCode } = await runCommand([
189
+ "send",
190
+ "--source-channel",
191
+ "scheduler",
192
+ "--source-event-name",
193
+ "reminder.fired",
194
+ "--message",
195
+ "Test",
196
+ "--urgency",
197
+ "high",
198
+ "--requires-action",
199
+ "--is-async-background",
200
+ ]);
201
+
202
+ expect(exitCode).toBe(0);
203
+ expect(parsed.ok).toBe(true);
204
+
205
+ expect(emitSignalCalls).toHaveLength(1);
206
+ const hints = emitSignalCalls[0].attentionHints as Record<string, unknown>;
207
+ expect(hints.urgency).toBe("high");
208
+ expect(hints.requiresAction).toBe(true);
209
+ expect(hints.isAsyncBackground).toBe(true);
210
+ });
211
+
212
+ test("send passes preferred channels", async () => {
213
+ const { parsed, exitCode } = await runCommand([
214
+ "send",
215
+ "--source-channel",
216
+ "assistant_tool",
217
+ "--source-event-name",
218
+ "user.send_notification",
219
+ "--message",
220
+ "Hello",
221
+ "--preferred-channels",
222
+ "telegram,slack",
223
+ ]);
224
+
225
+ expect(exitCode).toBe(0);
226
+ expect(parsed.ok).toBe(true);
227
+
228
+ expect(emitSignalCalls).toHaveLength(1);
229
+ const payload = emitSignalCalls[0].contextPayload as Record<
230
+ string,
231
+ unknown
232
+ >;
233
+ expect(payload.preferredChannels).toEqual(["telegram", "slack"]);
234
+ });
235
+
236
+ test("send rejects invalid source channel", async () => {
237
+ const { parsed, exitCode } = await runCommand([
238
+ "send",
239
+ "--source-channel",
240
+ "bogus",
241
+ "--source-event-name",
242
+ "user.send_notification",
243
+ "--message",
244
+ "Hello",
245
+ ]);
246
+
247
+ expect(exitCode).toBe(1);
248
+ expect(parsed.ok).toBe(false);
249
+ expect(parsed.error).toContain("bogus");
250
+ // Should list valid channels from the registry
251
+ expect(parsed.error).toContain("assistant_tool");
252
+ expect(parsed.error).toContain("scheduler");
253
+ expect(parsed.error).toContain("watcher");
254
+ });
255
+
256
+ test("send rejects invalid source event name", async () => {
257
+ const { parsed, exitCode } = await runCommand([
258
+ "send",
259
+ "--source-channel",
260
+ "assistant_tool",
261
+ "--source-event-name",
262
+ "bogus.event",
263
+ "--message",
264
+ "Hello",
265
+ ]);
266
+
267
+ expect(exitCode).toBe(1);
268
+ expect(parsed.ok).toBe(false);
269
+ expect(parsed.error).toContain("bogus.event");
270
+ // Should list valid event names from the registry
271
+ expect(parsed.error).toContain("user.send_notification");
272
+ expect(parsed.error).toContain("reminder.fired");
273
+ });
274
+
275
+ test("send rejects invalid urgency", async () => {
276
+ const { parsed, exitCode } = await runCommand([
277
+ "send",
278
+ "--source-channel",
279
+ "assistant_tool",
280
+ "--source-event-name",
281
+ "user.send_notification",
282
+ "--message",
283
+ "Hello",
284
+ "--urgency",
285
+ "invalid",
286
+ ]);
287
+
288
+ expect(exitCode).toBe(1);
289
+ expect(parsed.ok).toBe(false);
290
+ expect(parsed.error).toContain("invalid");
291
+ expect(parsed.error).toContain("low");
292
+ expect(parsed.error).toContain("medium");
293
+ expect(parsed.error).toContain("high");
294
+ });
295
+
296
+ test("send rejects invalid preferred channel", async () => {
297
+ const { parsed, exitCode } = await runCommand([
298
+ "send",
299
+ "--source-channel",
300
+ "assistant_tool",
301
+ "--source-event-name",
302
+ "user.send_notification",
303
+ "--message",
304
+ "Hello",
305
+ "--preferred-channels",
306
+ "badchannel",
307
+ ]);
308
+
309
+ expect(exitCode).toBe(1);
310
+ expect(parsed.ok).toBe(false);
311
+ expect(parsed.error).toContain("badchannel");
312
+ // Should list valid deliverable channels from the mock
313
+ expect(parsed.error).toContain("vellum");
314
+ expect(parsed.error).toContain("telegram");
315
+ expect(parsed.error).toContain("slack");
316
+ });
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // list subcommand
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe("notifications list", () => {
324
+ beforeEach(() => {
325
+ getDb().run("DELETE FROM notification_events");
326
+ });
327
+
328
+ test("list returns empty array when no events", async () => {
329
+ const { parsed, exitCode } = await runCommand(["list"]);
330
+
331
+ expect(exitCode).toBe(0);
332
+ expect(parsed.ok).toBe(true);
333
+ expect(parsed.events).toEqual([]);
334
+ });
335
+
336
+ test("list returns events", async () => {
337
+ createEvent({
338
+ id: `evt-${Date.now()}-1`,
339
+ sourceEventName: "user.send_notification",
340
+ sourceChannel: "assistant_tool",
341
+ sourceSessionId: "session-1",
342
+ attentionHints: {
343
+ requiresAction: true,
344
+ urgency: "medium",
345
+ isAsyncBackground: false,
346
+ visibleInSourceNow: false,
347
+ },
348
+ payload: { requestedMessage: "Test event" },
349
+ });
350
+
351
+ const { parsed, exitCode } = await runCommand(["list"]);
352
+
353
+ expect(exitCode).toBe(0);
354
+ expect(parsed.ok).toBe(true);
355
+ const events = parsed.events as Array<Record<string, unknown>>;
356
+ expect(events.length).toBeGreaterThanOrEqual(1);
357
+ expect(events[0].sourceEventName).toBe("user.send_notification");
358
+ });
359
+
360
+ test("list respects --limit", async () => {
361
+ for (let i = 0; i < 5; i++) {
362
+ createEvent({
363
+ id: `evt-limit-${Date.now()}-${i}`,
364
+ sourceEventName: "user.send_notification",
365
+ sourceChannel: "assistant_tool",
366
+ sourceSessionId: `session-limit-${i}`,
367
+ attentionHints: {
368
+ requiresAction: true,
369
+ urgency: "medium",
370
+ isAsyncBackground: false,
371
+ visibleInSourceNow: false,
372
+ },
373
+ payload: { requestedMessage: `Limit test ${i}` },
374
+ });
375
+ }
376
+
377
+ const { parsed, exitCode } = await runCommand(["list", "--limit", "2"]);
378
+
379
+ expect(exitCode).toBe(0);
380
+ expect(parsed.ok).toBe(true);
381
+ const events = parsed.events as Array<Record<string, unknown>>;
382
+ expect(events).toHaveLength(2);
383
+ });
384
+
385
+ test("list filters by --source-event-name", async () => {
386
+ createEvent({
387
+ id: `evt-filter-notif-${Date.now()}`,
388
+ sourceEventName: "user.send_notification",
389
+ sourceChannel: "assistant_tool",
390
+ sourceSessionId: "session-filter-1",
391
+ attentionHints: {
392
+ requiresAction: true,
393
+ urgency: "medium",
394
+ isAsyncBackground: false,
395
+ visibleInSourceNow: false,
396
+ },
397
+ payload: { requestedMessage: "Notification event" },
398
+ });
399
+
400
+ createEvent({
401
+ id: `evt-filter-reminder-${Date.now()}`,
402
+ sourceEventName: "reminder.fired",
403
+ sourceChannel: "scheduler",
404
+ sourceSessionId: "session-filter-2",
405
+ attentionHints: {
406
+ requiresAction: true,
407
+ urgency: "high",
408
+ isAsyncBackground: false,
409
+ visibleInSourceNow: false,
410
+ },
411
+ payload: { requestedMessage: "Reminder event" },
412
+ });
413
+
414
+ const { parsed, exitCode } = await runCommand([
415
+ "list",
416
+ "--source-event-name",
417
+ "user.send_notification",
418
+ ]);
419
+
420
+ expect(exitCode).toBe(0);
421
+ expect(parsed.ok).toBe(true);
422
+ const events = parsed.events as Array<Record<string, unknown>>;
423
+ expect(events.length).toBeGreaterThanOrEqual(1);
424
+ for (const event of events) {
425
+ expect(event.sourceEventName).toBe("user.send_notification");
426
+ }
427
+ });
428
+
429
+ test("list accepts custom (non-registered) source event names", async () => {
430
+ createEvent({
431
+ id: `evt-custom-${Date.now()}`,
432
+ sourceEventName: "custom.my_event",
433
+ sourceChannel: "assistant_tool",
434
+ sourceSessionId: "session-custom",
435
+ attentionHints: {
436
+ requiresAction: true,
437
+ urgency: "medium",
438
+ isAsyncBackground: false,
439
+ visibleInSourceNow: false,
440
+ },
441
+ payload: { requestedMessage: "Custom event" },
442
+ });
443
+
444
+ const { parsed, exitCode } = await runCommand([
445
+ "list",
446
+ "--source-event-name",
447
+ "custom.my_event",
448
+ ]);
449
+
450
+ expect(exitCode).toBe(0);
451
+ expect(parsed.ok).toBe(true);
452
+ const events = parsed.events as Array<Record<string, unknown>>;
453
+ expect(events.length).toBeGreaterThanOrEqual(1);
454
+ for (const event of events) {
455
+ expect(event.sourceEventName).toBe("custom.my_event");
456
+ }
457
+ });
458
+
459
+ test("list returns empty for non-matching custom event name", async () => {
460
+ const { parsed, exitCode } = await runCommand([
461
+ "list",
462
+ "--source-event-name",
463
+ "nonexistent.event",
464
+ ]);
465
+
466
+ expect(exitCode).toBe(0);
467
+ expect(parsed.ok).toBe(true);
468
+ expect(parsed.events).toEqual([]);
469
+ });
470
+ });