@vellumai/assistant 0.10.3-staging.2 → 0.10.4-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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -7,25 +7,48 @@
7
7
  * so the PID-file bookkeeping lives here in one place.
8
8
  */
9
9
 
10
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
10
+ import {
11
+ closeSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { dirname } from "node:path";
11
20
 
12
- import { getMemoryWorkerPidPath } from "../util/platform.js";
21
+ import { getCurrentLogFilePath } from "../util/logger.js";
22
+ import {
23
+ getMemorySyncRunnerMarkerPath,
24
+ getMemoryWorkerPidPath,
25
+ } from "../util/platform.js";
13
26
 
14
27
  export interface MemoryWorkerStatus {
15
28
  status: "running" | "not_running";
16
29
  pid?: number;
17
30
  }
18
31
 
32
+ /** True when `err` is a Node ESRCH error ("no such process"). */
33
+ function isEsrchError(err: unknown): boolean {
34
+ return (
35
+ !!err &&
36
+ typeof err === "object" &&
37
+ "code" in err &&
38
+ (err as { code?: unknown }).code === "ESRCH"
39
+ );
40
+ }
41
+
19
42
  /**
20
- * Inspect the PID file to determine whether the worker process is alive.
21
- * A stale PID file (pointing at a dead process) is cleaned up and reported
22
- * as not_running.
43
+ * Read a PID file and report liveness. A missing or malformed file reports
44
+ * not_running; a file pointing at a dead process is cleaned up and reported as
45
+ * not_running. Shared by the worker-process PID file and the sync-runner
46
+ * marker so both probe identically.
23
47
  */
24
- export function probeMemoryWorker(): MemoryWorkerStatus {
25
- const pidPath = getMemoryWorkerPidPath();
26
- if (!existsSync(pidPath)) return { status: "not_running" };
48
+ function probePidFile(path: string): MemoryWorkerStatus {
49
+ if (!existsSync(path)) return { status: "not_running" };
27
50
 
28
- const raw = readFileSync(pidPath, "utf-8").trim();
51
+ const raw = readFileSync(path, "utf-8").trim();
29
52
  const pid = parseInt(raw, 10);
30
53
  if (!Number.isFinite(pid) || pid <= 0) return { status: "not_running" };
31
54
 
@@ -33,15 +56,10 @@ export function probeMemoryWorker(): MemoryWorkerStatus {
33
56
  process.kill(pid, 0);
34
57
  return { status: "running", pid };
35
58
  } catch (err: unknown) {
36
- if (
37
- err &&
38
- typeof err === "object" &&
39
- "code" in err &&
40
- err.code === "ESRCH"
41
- ) {
42
- // Stale PID file — clean it up.
59
+ if (isEsrchError(err)) {
60
+ // Stale file — clean it up.
43
61
  try {
44
- unlinkSync(pidPath);
62
+ unlinkSync(path);
45
63
  } catch {
46
64
  // best-effort
47
65
  }
@@ -51,6 +69,53 @@ export function probeMemoryWorker(): MemoryWorkerStatus {
51
69
  }
52
70
  }
53
71
 
72
+ /**
73
+ * Inspect the PID file to determine whether the worker process is alive.
74
+ * A stale PID file (pointing at a dead process) is cleaned up and reported
75
+ * as not_running.
76
+ */
77
+ export function probeMemoryWorker(): MemoryWorkerStatus {
78
+ return probePidFile(getMemoryWorkerPidPath());
79
+ }
80
+
81
+ /**
82
+ * Inspect the sync-runner marker to determine whether the daemon's in-process
83
+ * synchronous runner is currently draining the memory-job queue. The daemon's
84
+ * worker supervisor writes the marker (with its own PID) only while it owns
85
+ * processing, so a live marker means the synchronous runner is going. A stale
86
+ * marker (daemon gone) is cleaned up and reported as not_running.
87
+ */
88
+ export function probeSyncRunner(): MemoryWorkerStatus {
89
+ return probePidFile(getMemorySyncRunnerMarkerPath());
90
+ }
91
+
92
+ /**
93
+ * Publish the sync-runner marker recording `pid` (the daemon process). Called
94
+ * by the worker supervisor when its in-process synchronous runner takes over
95
+ * processing. Best-effort: a write failure only affects status reporting, not
96
+ * job processing.
97
+ */
98
+ export function writeSyncRunnerMarker(pid: number): void {
99
+ try {
100
+ writeFileSync(getMemorySyncRunnerMarkerPath(), String(pid), { flag: "w" });
101
+ } catch {
102
+ // best-effort — the marker is a status hint, not a correctness invariant
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Remove the sync-runner marker. Called by the worker supervisor when it stands
108
+ * down for an out-of-process worker, and on daemon shutdown. Best-effort.
109
+ */
110
+ export function removeSyncRunnerMarker(): void {
111
+ try {
112
+ const path = getMemorySyncRunnerMarkerPath();
113
+ if (existsSync(path)) unlinkSync(path);
114
+ } catch {
115
+ // best-effort
116
+ }
117
+ }
118
+
54
119
  export class MemoryWorkerSpawnError extends Error {}
55
120
 
56
121
  /**
@@ -72,13 +137,34 @@ export async function spawnMemoryWorkerProcess(): Promise<{
72
137
  const pidPath = getMemoryWorkerPidPath();
73
138
  const entry = new URL("./worker-process.ts", import.meta.url);
74
139
 
140
+ // Pipe the worker's stderr into the same daily log file the daemon
141
+ // writes to. The worker's pino logger already writes there directly,
142
+ // but stderr captures crash traces (uncaught exceptions that bypass
143
+ // the catch handler) and pino's fallback output if the file logger
144
+ // fails to initialize. Without this, any such output is lost to
145
+ // /dev/null and the worker dies silently.
146
+ let stderrFd: number | "inherit" = "inherit";
147
+ try {
148
+ const logPath = getCurrentLogFilePath();
149
+ mkdirSync(dirname(logPath), { recursive: true });
150
+ stderrFd = openSync(logPath, "a", 0o600);
151
+ } catch {
152
+ // If the log file can't be opened, inherit the parent's stderr so
153
+ // crash output is at least visible to the spawning process.
154
+ }
155
+
75
156
  // Spawn detached so the worker survives the spawning process exiting.
76
157
  const child = Bun.spawn({
77
158
  cmd: ["bun", "run", entry.pathname],
78
- stdio: ["ignore", "ignore", "ignore"],
159
+ stdio: ["ignore", "ignore", stderrFd],
79
160
  detached: true,
80
161
  });
81
162
 
163
+ // Close our copy of the log fd — the child has its own.
164
+ if (typeof stderrFd === "number") {
165
+ closeSync(stderrFd);
166
+ }
167
+
82
168
  // Unreference so the spawning process doesn't wait for the child.
83
169
  child.unref();
84
170
 
@@ -58,6 +58,23 @@ async function main(): Promise<void> {
58
58
  process.on("SIGTERM", () => shutdown("SIGTERM"));
59
59
  process.on("SIGINT", () => shutdown("SIGINT"));
60
60
 
61
+ // Catch stray exceptions that escape the worker loop so they produce a
62
+ // clean pino-formatted log entry (and PID-file cleanup) instead of a raw
63
+ // stack trace on stderr. The stderr fd is already piped to the log file
64
+ // by the spawner, so even without these handlers the trace would be
65
+ // captured — but this gives us structured logging and graceful shutdown.
66
+ process.on("uncaughtException", (err) => {
67
+ log.error({ err }, "Uncaught exception in memory worker process");
68
+ cleanupPidFile();
69
+ process.exit(1);
70
+ });
71
+
72
+ process.on("unhandledRejection", (reason) => {
73
+ log.error({ reason }, "Unhandled rejection in memory worker process");
74
+ cleanupPidFile();
75
+ process.exit(1);
76
+ });
77
+
61
78
  // Clean up if the process exits unexpectedly through any other path.
62
79
  process.on("exit", () => {
63
80
  worker.stop();
@@ -0,0 +1,31 @@
1
+ import type { ExternalConversationBinding } from "../memory/external-conversation-store.js";
2
+ import type { ChannelBindingMetadata } from "./channel-binding-schema.js";
3
+ import { buildSlackBindingMetadata } from "./providers/slack/binding-metadata.js";
4
+
5
+ type BindingMetadataBuilder = (
6
+ binding: ExternalConversationBinding,
7
+ ) => ChannelBindingMetadata | undefined;
8
+
9
+ /**
10
+ * Per-channel binding-metadata builders, keyed by source-channel id. A channel
11
+ * that can enrich a serialized conversation binding (e.g. with deep links back
12
+ * to the source message) registers its builder here; channels without an entry
13
+ * contribute nothing.
14
+ *
15
+ * Wired statically on purpose: conversation serialization runs in contexts that
16
+ * never boot the daemon (unit tests, CLI tooling), so this must not depend on
17
+ * the lifecycle-registered messaging-provider registry.
18
+ */
19
+ const BINDING_METADATA_BUILDERS: Record<string, BindingMetadataBuilder> = {
20
+ slack: buildSlackBindingMetadata,
21
+ };
22
+
23
+ /**
24
+ * Channel-specific fields to merge onto a serialized channel binding, or
25
+ * `undefined` when the source channel contributes none.
26
+ */
27
+ export function buildChannelBindingMetadata(
28
+ binding: ExternalConversationBinding,
29
+ ): ChannelBindingMetadata | undefined {
30
+ return BINDING_METADATA_BUILDERS[binding.sourceChannel]?.(binding);
31
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+
3
+ const slackThreadSchema = z.object({
4
+ channelId: z.string(),
5
+ threadTs: z.string(),
6
+ link: z
7
+ .object({
8
+ appUrl: z.string().optional(),
9
+ webUrl: z.string().optional(),
10
+ })
11
+ .optional(),
12
+ });
13
+
14
+ const slackChannelSchema = z.object({
15
+ channelId: z.string(),
16
+ name: z.string().optional(),
17
+ link: z.object({ webUrl: z.string() }).optional(),
18
+ });
19
+
20
+ /**
21
+ * Wire shape of a serialized conversation channel binding — the single source
22
+ * of truth for this contract.
23
+ *
24
+ * Consumed as a route `responseBody` (which drives `openapi.yaml` generation
25
+ * and, in turn, the web client's generated daemon types), and the server-side
26
+ * builders derive their TypeScript types from it via `z.infer`. The shape is
27
+ * therefore declared exactly once.
28
+ */
29
+ export const channelBindingSchema = z.object({
30
+ sourceChannel: z.string(),
31
+ externalChatId: z.string(),
32
+ externalChatName: z.string().optional(),
33
+ externalThreadId: z.string().optional(),
34
+ externalUserId: z.string().nullable(),
35
+ displayName: z.string().nullable(),
36
+ username: z.string().nullable(),
37
+ slackThread: slackThreadSchema.optional(),
38
+ slackChannel: slackChannelSchema.optional(),
39
+ });
40
+
41
+ type ChannelBinding = z.infer<typeof channelBindingSchema>;
42
+
43
+ /**
44
+ * The channel-specific fields a per-channel builder contributes to a binding
45
+ * (everything beyond the channel-neutral base). Picked from the schema above
46
+ * so a builder's output can never drift from the wire contract.
47
+ */
48
+ export type ChannelBindingMetadata = Pick<
49
+ ChannelBinding,
50
+ "externalChatName" | "slackThread" | "slackChannel"
51
+ >;
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { channelForCallback } from "../callback-routing.js";
4
+
5
+ describe("channelForCallback", () => {
6
+ test("resolves each direct-delivery channel from its callback URL", () => {
7
+ expect(channelForCallback("http://gw/deliver/slack?threadTs=1")).toBe(
8
+ "slack",
9
+ );
10
+ expect(channelForCallback("http://gw/deliver/telegram")).toBe("telegram");
11
+ expect(channelForCallback("http://gw/deliver/whatsapp")).toBe("whatsapp");
12
+ expect(channelForCallback("http://gw/deliver/a2a?taskId=t1")).toBe("a2a");
13
+ });
14
+
15
+ test("returns undefined for channels not delivered directly", () => {
16
+ expect(channelForCallback("http://gw/deliver/discord")).toBeUndefined();
17
+ expect(channelForCallback("http://gw/deliver/phone")).toBeUndefined();
18
+ });
19
+
20
+ test("returns undefined for non-delivery paths", () => {
21
+ expect(channelForCallback("http://gw/v1/messages")).toBeUndefined();
22
+ expect(
23
+ channelForCallback(
24
+ "http://gw/v1/internal/managed-gateway/outbound-send/?route_id=r1",
25
+ ),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ test("returns undefined for unparseable input", () => {
30
+ expect(channelForCallback("not-a-url")).toBeUndefined();
31
+ });
32
+
33
+ test("resolves base-less callback paths", () => {
34
+ expect(channelForCallback("/deliver/slack?threadTs=1")).toBe("slack");
35
+ });
36
+
37
+ test("resolves relative guardian-style /deliver/<channel> callbacks", () => {
38
+ // resolveDeliverCallbackUrlForChannel() emits these relative URLs for
39
+ // off-channel guardian approvals/denials and timer-driven expiry notices;
40
+ // they must route as direct delivery, not fall through to the HTTP proxy.
41
+ expect(channelForCallback("/deliver/slack")).toBe("slack");
42
+ expect(channelForCallback("/deliver/telegram")).toBe("telegram");
43
+ expect(channelForCallback("/deliver/whatsapp")).toBe("whatsapp");
44
+ });
45
+ });
@@ -0,0 +1,195 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { ChannelReplyPayload } from "@vellumai/gateway-client";
4
+
5
+ // Replace each channel's provider-API send layer with spies so the dispatcher's
6
+ // routing and sub-operation selection can be asserted without network calls.
7
+ const slack = {
8
+ sendSlackReply: mock((..._args: unknown[]) =>
9
+ Promise.resolve({ ts: "slack-ts" }),
10
+ ),
11
+ sendSlackReaction: mock((..._args: unknown[]) => Promise.resolve()),
12
+ sendSlackTypingIndicator: mock((..._args: unknown[]) =>
13
+ Promise.resolve("typing-ts"),
14
+ ),
15
+ sendSlackAssistantThreadStatus: mock((..._args: unknown[]) =>
16
+ Promise.resolve(),
17
+ ),
18
+ sendSlackAttachments: mock((..._args: unknown[]) =>
19
+ Promise.resolve({ allFailed: false, failureCount: 0 }),
20
+ ),
21
+ };
22
+ const telegram = {
23
+ sendTelegramReply: mock((..._args: unknown[]) => Promise.resolve()),
24
+ sendTelegramTypingIndicator: mock((..._args: unknown[]) => Promise.resolve()),
25
+ sendTelegramAttachments: mock((..._args: unknown[]) =>
26
+ Promise.resolve({ allFailed: false, failureCount: 0 }),
27
+ ),
28
+ };
29
+ const whatsapp = {
30
+ sendWhatsAppReply: mock((..._args: unknown[]) => Promise.resolve()),
31
+ sendWhatsAppAttachments: mock((..._args: unknown[]) =>
32
+ Promise.resolve({ allFailed: false, failureCount: 0 }),
33
+ ),
34
+ };
35
+ const a2a = {
36
+ deliverA2AReply: mock((..._args: unknown[]) => Promise.resolve({ ok: true })),
37
+ };
38
+
39
+ mock.module("../slack/send.js", () => slack);
40
+ mock.module("../telegram-bot/send.js", () => telegram);
41
+ mock.module("../whatsapp/send.js", () => whatsapp);
42
+ mock.module("../a2a/deliver.js", () => a2a);
43
+ mock.module("../../../util/logger.js", () => ({
44
+ getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
45
+ }));
46
+
47
+ const { deliverDirect, isDirectDelivery, getTransportForCallback } =
48
+ await import("../index.js");
49
+
50
+ const BASE = "https://gateway.internal";
51
+
52
+ function payload(
53
+ overrides: Partial<ChannelReplyPayload> = {},
54
+ ): ChannelReplyPayload {
55
+ return { chatId: "C1", ...overrides };
56
+ }
57
+
58
+ beforeEach(() => {
59
+ for (const group of [slack, telegram, whatsapp, a2a]) {
60
+ for (const spy of Object.values(group)) spy.mockClear();
61
+ }
62
+ });
63
+
64
+ describe("routing", () => {
65
+ test("resolves each channel's callback path to its transport", () => {
66
+ expect(
67
+ getTransportForCallback(`${BASE}/deliver/slack?threadTs=1`)?.channel,
68
+ ).toBe("slack");
69
+ expect(getTransportForCallback(`${BASE}/deliver/telegram`)?.channel).toBe(
70
+ "telegram",
71
+ );
72
+ expect(getTransportForCallback(`${BASE}/deliver/whatsapp`)?.channel).toBe(
73
+ "whatsapp",
74
+ );
75
+ expect(
76
+ getTransportForCallback(`${BASE}/deliver/a2a?taskId=t1`)?.channel,
77
+ ).toBe("a2a");
78
+ });
79
+
80
+ test("isDirectDelivery is true for owned paths, false otherwise", () => {
81
+ expect(isDirectDelivery(`${BASE}/deliver/slack`)).toBe(true);
82
+ expect(isDirectDelivery(`${BASE}/deliver/a2a?taskId=t1`)).toBe(true);
83
+ expect(isDirectDelivery(`${BASE}/deliver/discord`)).toBe(false);
84
+ expect(isDirectDelivery(`${BASE}/v1/messages`)).toBe(false);
85
+ expect(
86
+ isDirectDelivery(
87
+ `${BASE}/v1/internal/managed-gateway/outbound-send/?route_id=r1`,
88
+ ),
89
+ ).toBe(false);
90
+ expect(getTransportForCallback(`${BASE}/deliver/discord`)).toBeUndefined();
91
+ });
92
+ });
93
+
94
+ describe("Slack sub-operation selection", () => {
95
+ test("text routes to sendSlackReply, threading the callback URL's threadTs", async () => {
96
+ await deliverDirect(
97
+ `${BASE}/deliver/slack?threadTs=1700.5`,
98
+ payload({ text: "hi" }),
99
+ );
100
+ expect(slack.sendSlackReply).toHaveBeenCalledTimes(1);
101
+ const opts = slack.sendSlackReply.mock.calls[0][2] as { threadTs?: string };
102
+ expect(opts.threadTs).toBe("1700.5");
103
+ expect(slack.sendSlackReaction).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test("threads a base-less callback URL's threadTs", async () => {
107
+ await deliverDirect(
108
+ `/deliver/slack?threadTs=1700.9`,
109
+ payload({ text: "hi" }),
110
+ );
111
+ expect(slack.sendSlackReply).toHaveBeenCalledTimes(1);
112
+ const opts = slack.sendSlackReply.mock.calls[0][2] as { threadTs?: string };
113
+ expect(opts.threadTs).toBe("1700.9");
114
+ });
115
+
116
+ test("reaction routes to sendSlackReaction, not the text path", async () => {
117
+ await deliverDirect(
118
+ `${BASE}/deliver/slack`,
119
+ payload({
120
+ reaction: {
121
+ action: "add",
122
+ name: "white_check_mark",
123
+ messageTs: "1700.5",
124
+ },
125
+ }),
126
+ );
127
+ expect(slack.sendSlackReaction).toHaveBeenCalledTimes(1);
128
+ expect(slack.sendSlackReply).not.toHaveBeenCalled();
129
+ });
130
+
131
+ test("assistantThreadStatus routes to sendSlackAssistantThreadStatus", async () => {
132
+ await deliverDirect(
133
+ `${BASE}/deliver/slack`,
134
+ payload({
135
+ assistantThreadStatus: {
136
+ channel: "C1",
137
+ threadTs: "1700.5",
138
+ status: "is thinking",
139
+ },
140
+ }),
141
+ );
142
+ expect(slack.sendSlackAssistantThreadStatus).toHaveBeenCalledTimes(1);
143
+ expect(slack.sendSlackReply).not.toHaveBeenCalled();
144
+ });
145
+
146
+ test("typing routes to sendSlackTypingIndicator", async () => {
147
+ await deliverDirect(
148
+ `${BASE}/deliver/slack`,
149
+ payload({ chatAction: "typing" }),
150
+ );
151
+ expect(slack.sendSlackTypingIndicator).toHaveBeenCalledTimes(1);
152
+ expect(slack.sendSlackReply).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe("capability gating across channels", () => {
157
+ test("a reaction payload to Telegram falls through to deliver (no sendReaction)", async () => {
158
+ await deliverDirect(
159
+ `${BASE}/deliver/telegram`,
160
+ payload({
161
+ text: "hi",
162
+ reaction: { action: "add", name: "x", messageTs: "1" },
163
+ }),
164
+ );
165
+ expect(telegram.sendTelegramReply).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ test("typing to Telegram routes to its typing indicator", async () => {
169
+ await deliverDirect(
170
+ `${BASE}/deliver/telegram`,
171
+ payload({ chatAction: "typing" }),
172
+ );
173
+ expect(telegram.sendTelegramTypingIndicator).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ test("WhatsApp text routes to sendWhatsAppReply", async () => {
177
+ await deliverDirect(`${BASE}/deliver/whatsapp`, payload({ text: "hi" }));
178
+ expect(whatsapp.sendWhatsAppReply).toHaveBeenCalledTimes(1);
179
+ });
180
+
181
+ test("A2A routes to deliverA2AReply with the callback URL", async () => {
182
+ const url = `${BASE}/deliver/a2a?taskId=t1`;
183
+ await deliverDirect(url, payload({ text: "hi" }));
184
+ expect(a2a.deliverA2AReply).toHaveBeenCalledTimes(1);
185
+ expect(a2a.deliverA2AReply.mock.calls[0][0]).toBe(url);
186
+ });
187
+ });
188
+
189
+ describe("unsupported callback", () => {
190
+ test("throws when no transport owns the callback", async () => {
191
+ await expect(
192
+ deliverDirect(`${BASE}/deliver/discord`, payload({ text: "hi" })),
193
+ ).rejects.toThrow(/unsupported callback/);
194
+ });
195
+ });
@@ -167,6 +167,17 @@ describe("deliverA2AReply", () => {
167
167
  expect(completeWithArtifactsCalls).toHaveLength(0);
168
168
  });
169
169
 
170
+ test("completes task for a base-less (relative) callback URL", async () => {
171
+ const result = await deliverA2AReply("/deliver/a2a?taskId=task-123", {
172
+ chatId: "chat-1",
173
+ text: "Hello",
174
+ });
175
+
176
+ expect(result.ok).toBe(true);
177
+ expect(completeWithArtifactsCalls).toHaveLength(1);
178
+ expect(completeWithArtifactsCalls[0].taskId).toBe("task-123");
179
+ });
180
+
170
181
  test("returns ok: true when payload has no content", async () => {
171
182
  const result = await deliverA2AReply(baseCallbackUrl, {
172
183
  chatId: "chat-1",
@@ -36,7 +36,11 @@ const PUSH_TIMEOUT_MS = 15_000;
36
36
  /** Extract the `taskId` query parameter from a callback URL. */
37
37
  function parseTaskId(callbackUrl: string): string | null {
38
38
  try {
39
- return new URL(callbackUrl).searchParams.get("taskId");
39
+ // Dummy base so base-less callbacks (e.g. `/deliver/a2a?taskId=…`) parse the
40
+ // same as absolute ones — consistent with `callbackContext` in the dispatcher.
41
+ return new URL(callbackUrl, "http://callback.invalid").searchParams.get(
42
+ "taskId",
43
+ );
40
44
  } catch {
41
45
  return null;
42
46
  }
@@ -0,0 +1,10 @@
1
+ import type { ChannelTransport } from "../channel-transport.js";
2
+ import { deliverA2AReply } from "./deliver.js";
3
+
4
+ export const a2aTransport: ChannelTransport = {
5
+ channel: "a2a",
6
+
7
+ async deliver(ctx, payload) {
8
+ return deliverA2AReply(ctx.callbackUrl, payload);
9
+ },
10
+ };
@@ -0,0 +1,48 @@
1
+ import type { ChannelId } from "../../channels/types.js";
2
+
3
+ /**
4
+ * Channels whose outbound replies the assistant delivers directly to the
5
+ * provider API, bypassing the gateway HTTP proxy. Each is reached at the gateway
6
+ * callback path `/deliver/<channel>`.
7
+ *
8
+ * This is the single source of truth for that set and that mapping — shared by
9
+ * the delivery transports (`messaging/providers`) and any caller that needs to
10
+ * resolve a callback URL back to its channel WITHOUT loading the transport
11
+ * implementations (and their provider-API send code). Keep it dependency-light.
12
+ */
13
+ const DIRECT_DELIVERY_CHANNELS = [
14
+ "slack",
15
+ "telegram",
16
+ "whatsapp",
17
+ "a2a",
18
+ ] as const satisfies readonly ChannelId[];
19
+
20
+ export type DirectDeliveryChannel = (typeof DIRECT_DELIVERY_CHANNELS)[number];
21
+
22
+ const CALLBACK_PREFIX = "/deliver/";
23
+
24
+ /**
25
+ * Resolve a gateway callback URL to the direct-delivery channel that owns it, or
26
+ * `undefined` when no channel delivers it directly.
27
+ *
28
+ * Handles both absolute callbacks (gateway webhook replies) and base-less
29
+ * relative ones like `/deliver/slack`, which off-channel guardian flows emit
30
+ * (`resolveDeliverCallbackUrlForChannel`, the guardian expiry sweep). The
31
+ * query-stripped fallback is required, not defensive: without it those relative
32
+ * notices fall through to the HTTP proxy, which cannot `fetch()` a base-less URL.
33
+ */
34
+ export function channelForCallback(
35
+ callbackUrl: string,
36
+ ): DirectDeliveryChannel | undefined {
37
+ let pathname: string;
38
+ try {
39
+ pathname = new URL(callbackUrl).pathname;
40
+ } catch {
41
+ pathname = callbackUrl.split("?", 1)[0];
42
+ }
43
+ if (!pathname.startsWith(CALLBACK_PREFIX)) return undefined;
44
+ const channel = pathname.slice(CALLBACK_PREFIX.length);
45
+ return (DIRECT_DELIVERY_CHANNELS as readonly string[]).includes(channel)
46
+ ? (channel as DirectDeliveryChannel)
47
+ : undefined;
48
+ }
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ChannelDeliveryResult,
3
+ ChannelReplyPayload,
4
+ } from "@vellumai/gateway-client";
5
+
6
+ import type { ChannelId } from "../../channels/types.js";
7
+
8
+ /**
9
+ * Per-channel state carried on the gateway callback URL (e.g. Slack `threadTs`,
10
+ * A2A `taskId`). The dispatcher parses the URL once; each transport reads only
11
+ * the params it needs.
12
+ */
13
+ export interface CallbackContext {
14
+ readonly callbackUrl: string;
15
+ readonly params: Readonly<Record<string, string>>;
16
+ }
17
+
18
+ /**
19
+ * Direct outbound delivery for one channel, wrapping the channel's provider-API
20
+ * send functions behind a uniform surface. Transports are registered statically
21
+ * (delivery runs in non-daemon contexts) and dispatched by channel, resolved
22
+ * from the gateway callback URL via `callback-routing.ts`.
23
+ *
24
+ * The dispatcher routes a payload to the optional sub-operation methods when the
25
+ * matching payload field is set and the method exists; otherwise it calls
26
+ * `deliver`. A transport only implements the sub-operations it supports.
27
+ */
28
+ export interface ChannelTransport {
29
+ /** Canonical source channel id, e.g. `"slack"`. */
30
+ readonly channel: ChannelId;
31
+
32
+ /** Deliver a rendered reply (text / approval / attachments). */
33
+ deliver(
34
+ ctx: CallbackContext,
35
+ payload: ChannelReplyPayload,
36
+ ): Promise<ChannelDeliveryResult>;
37
+
38
+ /** Send a typing indicator. Routed when `payload.chatAction === "typing"`. */
39
+ sendTyping?(
40
+ ctx: CallbackContext,
41
+ payload: ChannelReplyPayload,
42
+ ): Promise<ChannelDeliveryResult>;
43
+
44
+ /** Add an emoji reaction. Routed when `payload.reaction` is set. */
45
+ sendReaction?(
46
+ ctx: CallbackContext,
47
+ payload: ChannelReplyPayload,
48
+ ): Promise<ChannelDeliveryResult>;
49
+
50
+ /** Update an assistant-thread status surface. Routed when `payload.assistantThreadStatus` is set. */
51
+ setThreadStatus?(
52
+ ctx: CallbackContext,
53
+ payload: ChannelReplyPayload,
54
+ ): Promise<ChannelDeliveryResult>;
55
+ }