@vellumai/assistant 0.3.15 → 0.3.18

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 (306) hide show
  1. package/ARCHITECTURE.md +211 -12
  2. package/Dockerfile +1 -1
  3. package/README.md +11 -5
  4. package/docs/architecture/http-token-refresh.md +274 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +328 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +19 -15
  22. package/src/__tests__/checker.test.ts +103 -48
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +356 -0
  25. package/src/__tests__/conversation-pairing.test.ts +127 -27
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +425 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-store.test.ts +182 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  40. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  41. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  42. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  43. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  44. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  45. package/src/__tests__/hooks-runner.test.ts +13 -4
  46. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  47. package/src/__tests__/intent-routing.test.ts +14 -0
  48. package/src/__tests__/ipc-snapshot.test.ts +23 -5
  49. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  50. package/src/__tests__/memory-regressions.test.ts +16 -12
  51. package/src/__tests__/non-member-access-request.test.ts +281 -0
  52. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  53. package/src/__tests__/notification-decision-strategy.test.ts +138 -1
  54. package/src/__tests__/notification-deep-link.test.ts +44 -1
  55. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  56. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  57. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  58. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  59. package/src/__tests__/recording-intent.test.ts +1 -0
  60. package/src/__tests__/recording-state-machine.test.ts +328 -17
  61. package/src/__tests__/registry.test.ts +17 -8
  62. package/src/__tests__/relay-server.test.ts +105 -0
  63. package/src/__tests__/reminder.test.ts +13 -0
  64. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  65. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  66. package/src/__tests__/server-history-render.test.ts +8 -8
  67. package/src/__tests__/session-agent-loop.test.ts +1 -0
  68. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -0
  70. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  71. package/src/__tests__/slack-channel-config.test.ts +230 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  73. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  74. package/src/__tests__/system-prompt.test.ts +43 -0
  75. package/src/__tests__/task-management-tools.test.ts +3 -3
  76. package/src/__tests__/task-tools.test.ts +3 -3
  77. package/src/__tests__/trust-store.test.ts +38 -22
  78. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
  79. package/src/__tests__/trusted-contact-multichannel.test.ts +405 -0
  80. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  81. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  82. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  83. package/src/__tests__/update-bulletin.test.ts +323 -0
  84. package/src/__tests__/update-template-contract.test.ts +24 -0
  85. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  86. package/src/agent/loop.ts +2 -2
  87. package/src/amazon/client.ts +2 -3
  88. package/src/calls/call-controller.ts +241 -39
  89. package/src/calls/call-conversation-messages.ts +2 -2
  90. package/src/calls/call-domain.ts +10 -3
  91. package/src/calls/call-pointer-messages.ts +17 -5
  92. package/src/calls/guardian-action-sweep.ts +77 -36
  93. package/src/calls/guardian-dispatch.ts +8 -0
  94. package/src/calls/relay-server.ts +51 -12
  95. package/src/calls/twilio-routes.ts +3 -1
  96. package/src/calls/types.ts +1 -1
  97. package/src/calls/voice-session-bridge.ts +8 -6
  98. package/src/cli/core-commands.ts +43 -3
  99. package/src/cli/map.ts +8 -5
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  101. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  102. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  103. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  104. package/src/config/computer-use-prompt.ts +1 -0
  105. package/src/config/core-schema.ts +16 -0
  106. package/src/config/env-registry.ts +1 -0
  107. package/src/config/env.ts +16 -1
  108. package/src/config/memory-schema.ts +5 -0
  109. package/src/config/schema.ts +4 -0
  110. package/src/config/system-prompt.ts +69 -2
  111. package/src/config/templates/BOOTSTRAP.md +1 -1
  112. package/src/config/templates/IDENTITY.md +8 -4
  113. package/src/config/templates/SOUL.md +14 -0
  114. package/src/config/templates/UPDATES.md +15 -0
  115. package/src/config/templates/USER.md +5 -1
  116. package/src/config/types.ts +1 -0
  117. package/src/config/update-bulletin-format.ts +54 -0
  118. package/src/config/update-bulletin-state.ts +49 -0
  119. package/src/config/update-bulletin-template-path.ts +6 -0
  120. package/src/config/update-bulletin.ts +97 -0
  121. package/src/config/vellum-skills/catalog.json +6 -0
  122. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  123. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  124. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  125. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  126. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  127. package/src/context/window-manager.ts +43 -3
  128. package/src/daemon/config-watcher.ts +4 -2
  129. package/src/daemon/connection-policy.ts +21 -1
  130. package/src/daemon/daemon-control.ts +219 -8
  131. package/src/daemon/date-context.ts +174 -1
  132. package/src/daemon/guardian-action-generators.ts +175 -0
  133. package/src/daemon/guardian-verification-intent.ts +120 -0
  134. package/src/daemon/handlers/apps.ts +1 -3
  135. package/src/daemon/handlers/config-channels.ts +2 -2
  136. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  137. package/src/daemon/handlers/config-inbox.ts +55 -159
  138. package/src/daemon/handlers/config-ingress.ts +1 -1
  139. package/src/daemon/handlers/config-integrations.ts +1 -1
  140. package/src/daemon/handlers/config-platform.ts +1 -1
  141. package/src/daemon/handlers/config-scheduling.ts +2 -2
  142. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  143. package/src/daemon/handlers/config-telegram.ts +1 -1
  144. package/src/daemon/handlers/config-twilio.ts +1 -1
  145. package/src/daemon/handlers/config-voice.ts +100 -0
  146. package/src/daemon/handlers/config.ts +3 -0
  147. package/src/daemon/handlers/identity.ts +45 -25
  148. package/src/daemon/handlers/misc.ts +83 -5
  149. package/src/daemon/handlers/navigate-settings.ts +27 -0
  150. package/src/daemon/handlers/recording.ts +270 -144
  151. package/src/daemon/handlers/sessions.ts +100 -17
  152. package/src/daemon/handlers/subagents.ts +3 -3
  153. package/src/daemon/handlers/work-items.ts +10 -7
  154. package/src/daemon/ipc-contract/integrations.ts +9 -1
  155. package/src/daemon/ipc-contract/messages.ts +4 -0
  156. package/src/daemon/ipc-contract/sessions.ts +1 -1
  157. package/src/daemon/ipc-contract/settings.ts +26 -0
  158. package/src/daemon/ipc-contract/shared.ts +2 -0
  159. package/src/daemon/ipc-contract/work-items.ts +1 -7
  160. package/src/daemon/ipc-contract/workspace.ts +12 -1
  161. package/src/daemon/ipc-contract-inventory.json +6 -1
  162. package/src/daemon/ipc-contract.ts +5 -1
  163. package/src/daemon/lifecycle.ts +314 -266
  164. package/src/daemon/recording-intent.ts +0 -41
  165. package/src/daemon/response-tier.ts +2 -2
  166. package/src/daemon/server.ts +31 -9
  167. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  168. package/src/daemon/session-agent-loop.ts +15 -8
  169. package/src/daemon/session-history.ts +3 -2
  170. package/src/daemon/session-media-retry.ts +3 -0
  171. package/src/daemon/session-messaging.ts +38 -4
  172. package/src/daemon/session-notifiers.ts +2 -2
  173. package/src/daemon/session-process.ts +546 -59
  174. package/src/daemon/session-queue-manager.ts +2 -0
  175. package/src/daemon/session-runtime-assembly.ts +39 -0
  176. package/src/daemon/session-skill-tools.ts +13 -4
  177. package/src/daemon/session-tool-setup.ts +5 -6
  178. package/src/daemon/session.ts +19 -8
  179. package/src/daemon/tls-certs.ts +60 -13
  180. package/src/daemon/tool-side-effects.ts +13 -5
  181. package/src/gallery/default-gallery.ts +32 -9
  182. package/src/influencer/client.ts +2 -1
  183. package/src/memory/channel-delivery-store.ts +35 -567
  184. package/src/memory/channel-guardian-store.ts +63 -1317
  185. package/src/memory/conflict-store.ts +4 -4
  186. package/src/memory/conversation-attention-store.ts +0 -3
  187. package/src/memory/conversation-crud.ts +668 -0
  188. package/src/memory/conversation-queries.ts +361 -0
  189. package/src/memory/conversation-store.ts +44 -983
  190. package/src/memory/db-connection.ts +3 -0
  191. package/src/memory/db-init.ts +33 -0
  192. package/src/memory/delivery-channels.ts +175 -0
  193. package/src/memory/delivery-crud.ts +211 -0
  194. package/src/memory/delivery-status.ts +199 -0
  195. package/src/memory/embedding-backend.ts +70 -4
  196. package/src/memory/embedding-local.ts +12 -2
  197. package/src/memory/entity-extractor.ts +3 -8
  198. package/src/memory/fts-reconciler.ts +136 -0
  199. package/src/memory/guardian-action-store.ts +418 -5
  200. package/src/memory/guardian-approvals.ts +569 -0
  201. package/src/memory/guardian-bindings.ts +130 -0
  202. package/src/memory/guardian-rate-limits.ts +196 -0
  203. package/src/memory/guardian-verification.ts +521 -0
  204. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  205. package/src/memory/job-utils.ts +8 -5
  206. package/src/memory/jobs-store.ts +66 -6
  207. package/src/memory/jobs-worker.ts +23 -1
  208. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  209. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  210. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  211. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  212. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  213. package/src/memory/migrations/100-core-tables.ts +1 -1
  214. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  215. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  216. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  217. package/src/memory/migrations/113-late-migrations.ts +1 -1
  218. package/src/memory/migrations/116-messages-fts.ts +13 -0
  219. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  220. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  221. package/src/memory/migrations/index.ts +10 -3
  222. package/src/memory/migrations/validate-migration-state.ts +114 -15
  223. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  224. package/src/memory/retriever.ts +46 -13
  225. package/src/memory/schema-migration.ts +4 -0
  226. package/src/memory/schema.ts +31 -8
  227. package/src/memory/search/semantic.ts +8 -90
  228. package/src/notifications/README.md +159 -18
  229. package/src/notifications/broadcaster.ts +69 -33
  230. package/src/notifications/conversation-pairing.ts +99 -21
  231. package/src/notifications/decision-engine.ts +176 -8
  232. package/src/notifications/deliveries-store.ts +39 -8
  233. package/src/notifications/emit-signal.ts +1 -0
  234. package/src/notifications/preferences-store.ts +7 -7
  235. package/src/notifications/thread-candidates.ts +269 -0
  236. package/src/notifications/types.ts +19 -0
  237. package/src/permissions/checker.ts +1 -16
  238. package/src/permissions/defaults.ts +25 -5
  239. package/src/permissions/prompter.ts +17 -0
  240. package/src/permissions/trust-store.ts +2 -0
  241. package/src/providers/failover.ts +19 -0
  242. package/src/providers/registry.ts +46 -1
  243. package/src/runtime/approval-message-composer.ts +1 -1
  244. package/src/runtime/channel-guardian-service.ts +15 -3
  245. package/src/runtime/channel-retry-sweep.ts +7 -2
  246. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  247. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  248. package/src/runtime/guardian-action-message-composer.ts +245 -0
  249. package/src/runtime/guardian-outbound-actions.ts +26 -6
  250. package/src/runtime/guardian-verification-templates.ts +15 -9
  251. package/src/runtime/http-errors.ts +93 -0
  252. package/src/runtime/http-server.ts +133 -44
  253. package/src/runtime/http-types.ts +53 -0
  254. package/src/runtime/ingress-service.ts +237 -0
  255. package/src/runtime/middleware/error-handler.ts +4 -3
  256. package/src/runtime/middleware/rate-limiter.ts +160 -0
  257. package/src/runtime/middleware/request-logger.ts +71 -0
  258. package/src/runtime/middleware/twilio-validation.ts +7 -6
  259. package/src/runtime/pending-interactions.ts +12 -0
  260. package/src/runtime/routes/access-request-decision.ts +215 -0
  261. package/src/runtime/routes/app-routes.ts +25 -18
  262. package/src/runtime/routes/approval-routes.ts +18 -47
  263. package/src/runtime/routes/attachment-routes.ts +15 -41
  264. package/src/runtime/routes/call-routes.ts +20 -20
  265. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  266. package/src/runtime/routes/contact-routes.ts +4 -9
  267. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  268. package/src/runtime/routes/conversation-routes.ts +26 -57
  269. package/src/runtime/routes/debug-routes.ts +71 -0
  270. package/src/runtime/routes/events-routes.ts +3 -2
  271. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  272. package/src/runtime/routes/identity-routes.ts +14 -10
  273. package/src/runtime/routes/inbound-conversation.ts +3 -2
  274. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  275. package/src/runtime/routes/ingress-routes.ts +174 -0
  276. package/src/runtime/routes/integration-routes.ts +78 -16
  277. package/src/runtime/routes/pairing-routes.ts +11 -10
  278. package/src/runtime/routes/secret-routes.ts +10 -18
  279. package/src/runtime/verification-rate-limiter.ts +83 -0
  280. package/src/schedule/schedule-store.ts +13 -1
  281. package/src/schedule/scheduler.ts +1 -1
  282. package/src/security/secret-ingress.ts +5 -2
  283. package/src/security/secret-scanner.ts +72 -6
  284. package/src/subagent/manager.ts +6 -4
  285. package/src/swarm/plan-validator.ts +4 -1
  286. package/src/tasks/task-runner.ts +3 -1
  287. package/src/tools/browser/api-map.ts +9 -6
  288. package/src/tools/calls/call-start.ts +20 -0
  289. package/src/tools/executor.ts +50 -568
  290. package/src/tools/permission-checker.ts +271 -0
  291. package/src/tools/registry.ts +14 -6
  292. package/src/tools/reminder/reminder-store.ts +7 -7
  293. package/src/tools/reminder/reminder.ts +6 -3
  294. package/src/tools/secret-detection-handler.ts +301 -0
  295. package/src/tools/subagent/message.ts +1 -1
  296. package/src/tools/system/voice-config.ts +62 -0
  297. package/src/tools/tasks/index.ts +3 -3
  298. package/src/tools/tasks/work-item-list.ts +3 -3
  299. package/src/tools/tasks/work-item-update.ts +4 -5
  300. package/src/tools/tool-approval-handler.ts +192 -0
  301. package/src/tools/tool-manifest.ts +2 -0
  302. package/src/version.ts +29 -2
  303. package/src/watcher/watcher-store.ts +9 -9
  304. package/src/work-items/work-item-runner.ts +9 -6
  305. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  306. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -0,0 +1,323 @@
1
+ import * as fs from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
7
+
8
+ // --- In-memory checkpoint store ---
9
+ const store = new Map<string, string>();
10
+
11
+ mock.module('../memory/checkpoints.js', () => ({
12
+ getMemoryCheckpoint: mock((key: string) => store.get(key) ?? null),
13
+ setMemoryCheckpoint: mock((key: string, value: string) => store.set(key, value)),
14
+ }));
15
+
16
+ // --- Temp directory for workspace paths ---
17
+ let tempDir: string;
18
+
19
+ // --- Temp directory for template files ---
20
+ // Avoids mutating the real source-controlled UPDATES.md template, preventing
21
+ // race conditions with parallel test execution and working tree corruption
22
+ // if the test process crashes.
23
+ let tempTemplateDir: string;
24
+
25
+ // Mock platform to avoid env-registry transitive imports.
26
+ // All needed exports are stubbed; getWorkspacePromptPath is the only one
27
+ // exercised by update-bulletin.ts.
28
+ mock.module('../util/platform.js', () => ({
29
+ getWorkspacePromptPath: mock((file: string) => join(tempDir, file)),
30
+ getWorkspaceDir: () => tempDir,
31
+ getRootDir: () => tempDir,
32
+ getDataDir: () => join(tempDir, 'data'),
33
+ getPlatformName: () => 'darwin',
34
+ isMacOS: () => false,
35
+ isLinux: () => false,
36
+ isWindows: () => false,
37
+ ensureDataDir: () => {},
38
+ getDbPath: () => '',
39
+ getLogPath: () => '',
40
+ getHistoryPath: () => '',
41
+ getHooksDir: () => '',
42
+ getSocketPath: () => '',
43
+ getSessionTokenPath: () => '',
44
+ getHttpTokenPath: () => '',
45
+ getPlatformTokenPath: () => '',
46
+ getPidPath: () => '',
47
+ getWorkspaceConfigPath: () => '',
48
+ getWorkspaceSkillsDir: () => '',
49
+ getWorkspaceHooksDir: () => '',
50
+ getIpcBlobDir: () => '',
51
+ getSandboxRootDir: () => '',
52
+ getSandboxWorkingDir: () => '',
53
+ getInterfacesDir: () => '',
54
+ getClipboardCommand: () => null,
55
+ readLockfile: () => null,
56
+ normalizeAssistantId: (id: string) => id,
57
+ writeLockfile: () => {},
58
+ readPlatformToken: () => null,
59
+ readSessionToken: () => null,
60
+ readHttpToken: () => null,
61
+ removeSocketFile: () => {},
62
+ getTCPPort: () => 8765,
63
+ isTCPEnabled: () => false,
64
+ getTCPHost: () => '127.0.0.1',
65
+ isIOSPairingEnabled: () => false,
66
+ migrateToDataLayout: () => {},
67
+ migratePath: () => {},
68
+ migrateToWorkspaceLayout: () => {},
69
+ }));
70
+
71
+ // Mock system-prompt to provide only stripCommentLines without pulling in
72
+ // the rest of the system-prompt transitive dependency tree.
73
+ mock.module('../config/system-prompt.js', () => {
74
+ // Inline a minimal implementation of stripCommentLines matching production behavior.
75
+ function stripCommentLines(content: string): string {
76
+ const normalized = content.replace(/\r\n/g, '\n');
77
+ let openFenceChar: string | null = null;
78
+ const filtered = normalized.split('\n').filter((line) => {
79
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
80
+ if (fenceMatch) {
81
+ const char = fenceMatch[1][0];
82
+ if (!openFenceChar) {
83
+ openFenceChar = char;
84
+ } else if (char === openFenceChar) {
85
+ openFenceChar = null;
86
+ }
87
+ }
88
+ if (openFenceChar) return true;
89
+ return !line.trimStart().startsWith('_');
90
+ });
91
+ return filtered
92
+ .join('\n')
93
+ .replace(/\n{3,}/g, '\n\n')
94
+ .trim();
95
+ }
96
+ return { stripCommentLines };
97
+ });
98
+
99
+ mock.module('../version.js', () => ({
100
+ APP_VERSION: '1.0.0',
101
+ }));
102
+
103
+ // Mock the template path module so tests read from a temp directory instead
104
+ // of the real source-controlled template file.
105
+ mock.module('../config/update-bulletin-template-path.js', () => ({
106
+ getTemplatePath: () => join(tempTemplateDir, 'UPDATES.md'),
107
+ }));
108
+
109
+ const { syncUpdateBulletinOnStartup } = await import('../config/update-bulletin.js');
110
+
111
+ const TEST_TEMPLATE = '## What\'s New\n\nTest release notes.\n';
112
+ const COMMENT_ONLY_TEMPLATE = '_ This is a comment-only template.\n_ No real content here.\n';
113
+
114
+ describe('syncUpdateBulletinOnStartup', () => {
115
+ beforeEach(() => {
116
+ store.clear();
117
+ tempDir = join(tmpdir(), `update-bulletin-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
118
+ mkdirSync(tempDir, { recursive: true });
119
+ tempTemplateDir = join(tmpdir(), `update-bulletin-tpl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
120
+ mkdirSync(tempTemplateDir, { recursive: true });
121
+ // Write a test template with real content so materialization proceeds
122
+ writeFileSync(join(tempTemplateDir, 'UPDATES.md'), TEST_TEMPLATE, 'utf-8');
123
+ });
124
+
125
+ afterEach(() => {
126
+ rmSync(tempDir, { recursive: true, force: true });
127
+ rmSync(tempTemplateDir, { recursive: true, force: true });
128
+ });
129
+
130
+ it('creates workspace file on first eligible run', () => {
131
+ const workspacePath = join(tempDir, 'UPDATES.md');
132
+ expect(existsSync(workspacePath)).toBe(false);
133
+
134
+ syncUpdateBulletinOnStartup();
135
+
136
+ expect(existsSync(workspacePath)).toBe(true);
137
+ const content = readFileSync(workspacePath, 'utf-8');
138
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
139
+ expect(content).toContain("What's New");
140
+ });
141
+
142
+ it('appends release block when workspace file exists without current marker', () => {
143
+ const workspacePath = join(tempDir, 'UPDATES.md');
144
+ const preExisting = '<!-- vellum-update-release:0.9.0 -->\nOld release notes.\n';
145
+ writeFileSync(workspacePath, preExisting, 'utf-8');
146
+
147
+ syncUpdateBulletinOnStartup();
148
+
149
+ const content = readFileSync(workspacePath, 'utf-8');
150
+ expect(content).toContain('<!-- vellum-update-release:0.9.0 -->');
151
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
152
+ expect(content).toContain('Old release notes.');
153
+ });
154
+
155
+ it('does not duplicate same marker on repeated runs', () => {
156
+ syncUpdateBulletinOnStartup();
157
+ const workspacePath = join(tempDir, 'UPDATES.md');
158
+ const afterFirst = readFileSync(workspacePath, 'utf-8');
159
+
160
+ syncUpdateBulletinOnStartup();
161
+ const afterSecond = readFileSync(workspacePath, 'utf-8');
162
+
163
+ expect(afterSecond).toBe(afterFirst);
164
+ });
165
+
166
+ it('skips completed release', () => {
167
+ store.set('updates:completed_releases', JSON.stringify(['1.0.0']));
168
+ const workspacePath = join(tempDir, 'UPDATES.md');
169
+
170
+ syncUpdateBulletinOnStartup();
171
+
172
+ expect(existsSync(workspacePath)).toBe(false);
173
+ });
174
+
175
+ it('adds current release to active set', () => {
176
+ syncUpdateBulletinOnStartup();
177
+
178
+ const raw = store.get('updates:active_releases');
179
+ expect(raw).toBeDefined();
180
+ const active: string[] = JSON.parse(raw!);
181
+ expect(active).toContain('1.0.0');
182
+ });
183
+
184
+ it('marks active releases as completed when UPDATES.md is deleted', () => {
185
+ // Pre-populate active releases in the store
186
+ store.set('updates:active_releases', JSON.stringify(['0.8.0', '0.9.0']));
187
+
188
+ // Workspace file does not exist — simulates the assistant having deleted it
189
+ const workspacePath = join(tempDir, 'UPDATES.md');
190
+ expect(existsSync(workspacePath)).toBe(false);
191
+
192
+ syncUpdateBulletinOnStartup();
193
+
194
+ // Active set should be cleared (except for the newly-added current release)
195
+ const activeRaw = store.get('updates:active_releases');
196
+ expect(activeRaw).toBeDefined();
197
+ const active: string[] = JSON.parse(activeRaw!);
198
+ // The old releases should not be in the active set
199
+ expect(active).not.toContain('0.8.0');
200
+ expect(active).not.toContain('0.9.0');
201
+
202
+ // The old releases should now be completed
203
+ const completedRaw = store.get('updates:completed_releases');
204
+ expect(completedRaw).toBeDefined();
205
+ const completed: string[] = JSON.parse(completedRaw!);
206
+ expect(completed).toContain('0.8.0');
207
+ expect(completed).toContain('0.9.0');
208
+ });
209
+
210
+ it('does not recreate completed release after deletion', () => {
211
+ // First run — creates the workspace file and marks 1.0.0 active
212
+ syncUpdateBulletinOnStartup();
213
+ const workspacePath = join(tempDir, 'UPDATES.md');
214
+ expect(existsSync(workspacePath)).toBe(true);
215
+
216
+ // Simulate assistant deleting the file to signal completion
217
+ rmSync(workspacePath);
218
+ expect(existsSync(workspacePath)).toBe(false);
219
+
220
+ // Second run — deletion-completion should mark 1.0.0 completed
221
+ syncUpdateBulletinOnStartup();
222
+
223
+ // The file should NOT be recreated since the release is now completed
224
+ expect(existsSync(workspacePath)).toBe(false);
225
+ });
226
+
227
+ it('merges pending old block with new release block', () => {
228
+ const workspacePath = join(tempDir, 'UPDATES.md');
229
+ // Pre-create workspace file with an old release block
230
+ const oldContent =
231
+ '<!-- vellum-update-release:0.9.0 -->\nOld release notes for 0.9.0.\n<!-- /vellum-update-release:0.9.0 -->\n';
232
+ writeFileSync(workspacePath, oldContent, 'utf-8');
233
+
234
+ syncUpdateBulletinOnStartup();
235
+
236
+ const content = readFileSync(workspacePath, 'utf-8');
237
+ // Both old and new release blocks should be present
238
+ expect(content).toContain('<!-- vellum-update-release:0.9.0 -->');
239
+ expect(content).toContain('Old release notes for 0.9.0.');
240
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
241
+ });
242
+
243
+ it('idempotent on repeated sync calls', () => {
244
+ // First call
245
+ syncUpdateBulletinOnStartup();
246
+ const workspacePath = join(tempDir, 'UPDATES.md');
247
+ const afterFirst = readFileSync(workspacePath, 'utf-8');
248
+
249
+ // Second call
250
+ syncUpdateBulletinOnStartup();
251
+ const afterSecond = readFileSync(workspacePath, 'utf-8');
252
+
253
+ expect(afterSecond).toBe(afterFirst);
254
+
255
+ // Third call for good measure
256
+ syncUpdateBulletinOnStartup();
257
+ const afterThird = readFileSync(workspacePath, 'utf-8');
258
+
259
+ expect(afterThird).toBe(afterFirst);
260
+ });
261
+
262
+ it('write path produces valid UTF-8 with trailing newline', () => {
263
+ syncUpdateBulletinOnStartup();
264
+ const workspacePath = join(tempDir, 'UPDATES.md');
265
+ const content = readFileSync(workspacePath, 'utf-8');
266
+
267
+ expect(content.length).toBeGreaterThan(0);
268
+ expect(content.endsWith('\n')).toBe(true);
269
+
270
+ // Verify round-trip through Buffer produces identical content (valid UTF-8)
271
+ const roundTripped = Buffer.from(content, 'utf-8').toString('utf-8');
272
+ expect(roundTripped).toBe(content);
273
+ });
274
+
275
+ it('no temp file leftovers after successful write', () => {
276
+ syncUpdateBulletinOnStartup();
277
+
278
+ const entries = readdirSync(tempDir);
279
+ const tmpFiles = entries.filter((e) => e.includes('.tmp.'));
280
+ expect(tmpFiles).toHaveLength(0);
281
+ });
282
+
283
+ it('skips materialization when template is comment-only', () => {
284
+ // Write a comment-only template fixture (no real content after stripping)
285
+ writeFileSync(join(tempTemplateDir, 'UPDATES.md'), COMMENT_ONLY_TEMPLATE, 'utf-8');
286
+
287
+ const workspacePath = join(tempDir, 'UPDATES.md');
288
+ syncUpdateBulletinOnStartup();
289
+
290
+ expect(existsSync(workspacePath)).toBe(false);
291
+ });
292
+
293
+ it('preserves existing file when atomic write fails', () => {
294
+ const workspacePath = join(tempDir, 'UPDATES.md');
295
+ const originalContent = '<!-- vellum-update-release:0.9.0 -->\nOriginal content.\n';
296
+ writeFileSync(workspacePath, originalContent, 'utf-8');
297
+
298
+ // Mock writeFileSync to throw when writing the temp file, simulating a
299
+ // disk-full or permission error deterministically (chmod-based approaches
300
+ // are unreliable when running as root or with CAP_DAC_OVERRIDE).
301
+ const originalWriteFileSync = fs.writeFileSync;
302
+ const spy = spyOn(fs, 'writeFileSync').mockImplementation((...args: Parameters<typeof fs.writeFileSync>) => {
303
+ if (typeof args[0] === 'string' && args[0].includes('.tmp.')) {
304
+ throw new Error('Simulated write failure');
305
+ }
306
+ return originalWriteFileSync(...args);
307
+ });
308
+ try {
309
+ expect(() => syncUpdateBulletinOnStartup()).toThrow('Simulated write failure');
310
+ } finally {
311
+ spy.mockRestore();
312
+ }
313
+
314
+ // Original content should be preserved (atomic write never renamed over it)
315
+ const content = readFileSync(workspacePath, 'utf-8');
316
+ expect(content).toBe(originalContent);
317
+
318
+ // No temp file leftovers
319
+ const entries = readdirSync(tempDir);
320
+ const tmpFiles = entries.filter((e: string) => e.includes('.tmp.'));
321
+ expect(tmpFiles).toHaveLength(0);
322
+ });
323
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Contract test: ensures the bundled UPDATES.md template exists and is readable.
3
+ *
4
+ * The template may be comment-only (no real content) for no-op releases —
5
+ * the bulletin system treats an empty-after-stripping template as a skip signal.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ const TEMPLATE_PATH = join(import.meta.dirname, '..', 'config', 'templates', 'UPDATES.md');
14
+
15
+ describe('UPDATES.md template contract', () => {
16
+ test('template file exists', () => {
17
+ expect(existsSync(TEMPLATE_PATH)).toBe(true);
18
+ });
19
+
20
+ test('template is a readable UTF-8 file', () => {
21
+ const content = readFileSync(TEMPLATE_PATH, 'utf-8');
22
+ expect(typeof content).toBe('string');
23
+ });
24
+ });
@@ -8,6 +8,18 @@ import type { ServerMessage } from '../daemon/ipc-protocol.js';
8
8
  import type { Session } from '../daemon/session.js';
9
9
 
10
10
  const testDir = mkdtempSync(join(tmpdir(), 'voice-bridge-test-'));
11
+ let mockedConfig: {
12
+ secretDetection: { enabled: boolean };
13
+ calls: { disclosure: { enabled: boolean; text: string } };
14
+ } = {
15
+ secretDetection: { enabled: false },
16
+ calls: {
17
+ disclosure: {
18
+ enabled: false,
19
+ text: '',
20
+ },
21
+ },
22
+ };
11
23
 
12
24
  mock.module('../util/platform.js', () => ({
13
25
  getRootDir: () => testDir,
@@ -29,15 +41,7 @@ mock.module('../util/logger.js', () => ({
29
41
  }));
30
42
 
31
43
  mock.module('../config/loader.js', () => ({
32
- getConfig: () => ({
33
- secretDetection: { enabled: false },
34
- calls: {
35
- disclosure: {
36
- enabled: false,
37
- text: '',
38
- },
39
- },
40
- }),
44
+ getConfig: () => mockedConfig,
41
45
  }));
42
46
 
43
47
  import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
@@ -85,6 +89,15 @@ function injectDeps(sessionFactory: () => Session): void {
85
89
 
86
90
  describe('voice-session-bridge', () => {
87
91
  beforeEach(() => {
92
+ mockedConfig = {
93
+ secretDetection: { enabled: false },
94
+ calls: {
95
+ disclosure: {
96
+ enabled: false,
97
+ text: '',
98
+ },
99
+ },
100
+ };
88
101
  const db = getDb();
89
102
  db.run('DELETE FROM messages');
90
103
  db.run('DELETE FROM conversations');
@@ -415,6 +428,93 @@ describe('voice-session-bridge', () => {
415
428
  expect(capturedGuardianContext).toEqual(guardianCtx);
416
429
  });
417
430
 
431
+ test('inbound non-guardian opener prompt uses pickup framing instead of outbound phrasing', async () => {
432
+ const conversation = createConversation('voice bridge inbound opener framing test');
433
+ const events: ServerMessage[] = [
434
+ { type: 'message_complete', sessionId: conversation.id },
435
+ ];
436
+
437
+ let capturedPrompt: string | null = null;
438
+ const session = {
439
+ ...makeStreamingSession(events),
440
+ setVoiceCallControlPrompt: (prompt: string | null) => {
441
+ if (prompt != null) capturedPrompt = prompt;
442
+ },
443
+ } as unknown as Session;
444
+
445
+ injectDeps(() => session);
446
+
447
+ await startVoiceTurn({
448
+ conversationId: conversation.id,
449
+ content: 'Hello there',
450
+ isInbound: true,
451
+ guardianContext: {
452
+ sourceChannel: 'voice',
453
+ actorRole: 'non-guardian',
454
+ },
455
+ onTextDelta: () => {},
456
+ onComplete: () => {},
457
+ onError: () => {},
458
+ });
459
+
460
+ await new Promise((r) => setTimeout(r, 50));
461
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
462
+ const prompt: string = capturedPrompt;
463
+
464
+ expect(prompt).toContain('this is an inbound call you are answering (not a call you initiated)');
465
+ expect(prompt).toContain('Introduce yourself once at the start using your assistant name if you know it');
466
+ expect(prompt).toContain('If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant.');
467
+ expect(prompt).toContain('Do NOT say "I\'m calling" or "I\'m calling on behalf of".');
468
+ });
469
+
470
+ test('inbound disclosure guidance is rewritten for pickup context', async () => {
471
+ mockedConfig = {
472
+ secretDetection: { enabled: false },
473
+ calls: {
474
+ disclosure: {
475
+ enabled: true,
476
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.',
477
+ },
478
+ },
479
+ };
480
+
481
+ const conversation = createConversation('voice bridge inbound disclosure rewrite test');
482
+ const events: ServerMessage[] = [
483
+ { type: 'message_complete', sessionId: conversation.id },
484
+ ];
485
+
486
+ let capturedPrompt: string | null = null;
487
+ const session = {
488
+ ...makeStreamingSession(events),
489
+ setVoiceCallControlPrompt: (prompt: string | null) => {
490
+ if (prompt != null) capturedPrompt = prompt;
491
+ },
492
+ } as unknown as Session;
493
+
494
+ injectDeps(() => session);
495
+
496
+ await startVoiceTurn({
497
+ conversationId: conversation.id,
498
+ content: 'Hi',
499
+ isInbound: true,
500
+ guardianContext: {
501
+ sourceChannel: 'voice',
502
+ actorRole: 'non-guardian',
503
+ },
504
+ onTextDelta: () => {},
505
+ onComplete: () => {},
506
+ onError: () => {},
507
+ });
508
+
509
+ await new Promise((r) => setTimeout(r, 50));
510
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
511
+ const prompt: string = capturedPrompt;
512
+
513
+ expect(prompt).toContain('At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.');
514
+ expect(prompt).toContain('rewrite any disclosure naturally for pickup context');
515
+ expect(prompt).toContain('Do NOT say "I\'m calling", "I called you", or "I\'m calling on behalf of".');
516
+ });
517
+
418
518
  test('auto-denies confirmation requests for non-guardian voice turns', async () => {
419
519
  const conversation = createConversation('voice bridge auto-deny non-guardian test');
420
520
 
package/src/agent/loop.ts CHANGED
@@ -85,7 +85,7 @@ export class AgentLoop {
85
85
 
86
86
  async run(
87
87
  messages: Message[],
88
- onEvent: (event: AgentEvent) => void,
88
+ onEvent: (event: AgentEvent) => void | Promise<void>,
89
89
  signal?: AbortSignal,
90
90
  requestId?: string,
91
91
  onCheckpoint?: (checkpoint: CheckpointInfo) => CheckpointDecision,
@@ -244,7 +244,7 @@ export class AgentLoop {
244
244
  };
245
245
  history.push(assistantMessage);
246
246
 
247
- onEvent({ type: 'message_complete', message: assistantMessage });
247
+ await onEvent({ type: 'message_complete', message: assistantMessage });
248
248
 
249
249
  // Check for tool use
250
250
  toolUseBlocks = response.content.filter(
@@ -50,7 +50,7 @@
50
50
 
51
51
  import type { ExtensionCommand, ExtensionResponse } from '../browser-extension-relay/protocol.js';
52
52
  import { extensionRelayServer } from '../browser-extension-relay/server.js';
53
- import { getRuntimeHttpPort } from '../config/env.js';
53
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
54
54
  import type { ExtractedCredential } from '../tools/browser/network-recording-types.js';
55
55
  import { readHttpToken } from '../util/platform.js';
56
56
  import {
@@ -81,8 +81,7 @@ export async function sendRelayCommand(command: Record<string, unknown>): Promis
81
81
  throw new Error('Browser extension relay is not connected and no HTTP token found. Is the daemon running?');
82
82
  }
83
83
 
84
- const port = getRuntimeHttpPort() ?? 7821;
85
- const resp = await fetch(`http://127.0.0.1:${port}/v1/browser-relay/command`, {
84
+ const resp = await fetch(`${getGatewayInternalBaseUrl()}/v1/browser-relay/command`, {
86
85
  method: 'POST',
87
86
  headers: {
88
87
  'Content-Type': 'application/json',