@vellumai/assistant 0.3.14 → 0.3.16

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 (295) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +2 -2
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -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 +331 -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 +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  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 +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-control-plane-policy.test.ts +1 -3
  40. package/src/__tests__/guardian-outbound-http.test.ts +202 -10
  41. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  42. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  43. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  44. package/src/__tests__/hooks-runner.test.ts +13 -4
  45. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  46. package/src/__tests__/intent-routing.test.ts +14 -0
  47. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  48. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  49. package/src/__tests__/memory-regressions.test.ts +16 -12
  50. package/src/__tests__/non-member-access-request.test.ts +282 -0
  51. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  52. package/src/__tests__/notification-routing-intent.test.ts +11 -2
  53. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  54. package/src/__tests__/recording-intent-fallback.test.ts +0 -1
  55. package/src/__tests__/recording-intent-handler.test.ts +6 -3
  56. package/src/__tests__/recording-intent.test.ts +3 -2
  57. package/src/__tests__/recording-state-machine.test.ts +337 -26
  58. package/src/__tests__/registry.test.ts +17 -8
  59. package/src/__tests__/relay-server.test.ts +105 -0
  60. package/src/__tests__/reminder.test.ts +13 -0
  61. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  63. package/src/__tests__/server-history-render.test.ts +8 -8
  64. package/src/__tests__/session-agent-loop.test.ts +1 -0
  65. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  66. package/src/__tests__/session-skill-tools.test.ts +1 -0
  67. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  68. package/src/__tests__/slack-channel-config.test.ts +230 -0
  69. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  70. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  71. package/src/__tests__/system-prompt.test.ts +43 -0
  72. package/src/__tests__/task-management-tools.test.ts +3 -3
  73. package/src/__tests__/task-tools.test.ts +3 -3
  74. package/src/__tests__/trust-store.test.ts +17 -1
  75. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  76. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  77. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  78. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  79. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  80. package/src/__tests__/update-bulletin.test.ts +260 -0
  81. package/src/__tests__/update-template-contract.test.ts +29 -0
  82. package/src/agent/loop.ts +2 -2
  83. package/src/amazon/client.ts +2 -3
  84. package/src/calls/call-controller.ts +115 -34
  85. package/src/calls/call-conversation-messages.ts +2 -2
  86. package/src/calls/call-domain.ts +10 -3
  87. package/src/calls/call-pointer-messages.ts +17 -5
  88. package/src/calls/guardian-action-sweep.ts +77 -36
  89. package/src/calls/relay-server.ts +51 -12
  90. package/src/calls/twilio-routes.ts +3 -1
  91. package/src/calls/types.ts +1 -1
  92. package/src/calls/voice-session-bridge.ts +4 -4
  93. package/src/cli/core-commands.ts +3 -3
  94. package/src/cli/map.ts +8 -5
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  96. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  97. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  98. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  99. package/src/config/computer-use-prompt.ts +1 -0
  100. package/src/config/core-schema.ts +16 -0
  101. package/src/config/env-registry.ts +1 -0
  102. package/src/config/env.ts +16 -1
  103. package/src/config/memory-schema.ts +5 -0
  104. package/src/config/schema.ts +4 -0
  105. package/src/config/system-prompt.ts +69 -2
  106. package/src/config/templates/BOOTSTRAP.md +1 -1
  107. package/src/config/templates/IDENTITY.md +8 -4
  108. package/src/config/templates/SOUL.md +14 -0
  109. package/src/config/templates/UPDATES.md +16 -0
  110. package/src/config/templates/USER.md +5 -1
  111. package/src/config/types.ts +1 -0
  112. package/src/config/update-bulletin-format.ts +52 -0
  113. package/src/config/update-bulletin-state.ts +49 -0
  114. package/src/config/update-bulletin.ts +82 -0
  115. package/src/config/vellum-skills/catalog.json +6 -0
  116. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  117. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  119. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  120. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  121. package/src/context/window-manager.ts +43 -3
  122. package/src/daemon/config-watcher.ts +1 -0
  123. package/src/daemon/connection-policy.ts +21 -1
  124. package/src/daemon/daemon-control.ts +164 -7
  125. package/src/daemon/date-context.ts +174 -1
  126. package/src/daemon/guardian-action-generators.ts +175 -0
  127. package/src/daemon/guardian-verification-intent.ts +120 -0
  128. package/src/daemon/handlers/apps.ts +1 -3
  129. package/src/daemon/handlers/config-channels.ts +8 -8
  130. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  131. package/src/daemon/handlers/config-inbox.ts +55 -159
  132. package/src/daemon/handlers/config-ingress.ts +1 -1
  133. package/src/daemon/handlers/config-integrations.ts +1 -1
  134. package/src/daemon/handlers/config-platform.ts +1 -1
  135. package/src/daemon/handlers/config-scheduling.ts +2 -2
  136. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  137. package/src/daemon/handlers/config-telegram.ts +1 -1
  138. package/src/daemon/handlers/config-twilio.ts +1 -1
  139. package/src/daemon/handlers/config-voice.ts +100 -0
  140. package/src/daemon/handlers/config.ts +3 -0
  141. package/src/daemon/handlers/index.ts +1 -1
  142. package/src/daemon/handlers/misc.ts +84 -6
  143. package/src/daemon/handlers/navigate-settings.ts +27 -0
  144. package/src/daemon/handlers/recording.ts +270 -144
  145. package/src/daemon/handlers/sessions.ts +107 -24
  146. package/src/daemon/handlers/subagents.ts +3 -3
  147. package/src/daemon/handlers/work-items.ts +10 -7
  148. package/src/daemon/ipc-contract/integrations.ts +9 -1
  149. package/src/daemon/ipc-contract/messages.ts +4 -0
  150. package/src/daemon/ipc-contract/sessions.ts +1 -1
  151. package/src/daemon/ipc-contract/settings.ts +26 -0
  152. package/src/daemon/ipc-contract/shared.ts +2 -0
  153. package/src/daemon/ipc-contract/work-items.ts +1 -7
  154. package/src/daemon/ipc-contract-inventory.json +5 -1
  155. package/src/daemon/ipc-contract.ts +5 -1
  156. package/src/daemon/lifecycle.ts +306 -266
  157. package/src/daemon/recording-executor.ts +1 -1
  158. package/src/daemon/recording-intent.ts +0 -41
  159. package/src/daemon/response-tier.ts +2 -2
  160. package/src/daemon/server.ts +6 -6
  161. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  162. package/src/daemon/session-agent-loop.ts +15 -8
  163. package/src/daemon/session-history.ts +3 -2
  164. package/src/daemon/session-media-retry.ts +3 -0
  165. package/src/daemon/session-messaging.ts +38 -4
  166. package/src/daemon/session-notifiers.ts +2 -2
  167. package/src/daemon/session-process.ts +256 -23
  168. package/src/daemon/session-queue-manager.ts +2 -0
  169. package/src/daemon/session-runtime-assembly.ts +39 -0
  170. package/src/daemon/session-skill-tools.ts +13 -4
  171. package/src/daemon/session-tool-setup.ts +6 -7
  172. package/src/daemon/session.ts +19 -8
  173. package/src/daemon/tls-certs.ts +55 -13
  174. package/src/daemon/tool-side-effects.ts +13 -5
  175. package/src/gallery/default-gallery.ts +32 -9
  176. package/src/influencer/client.ts +2 -1
  177. package/src/memory/channel-delivery-store.ts +37 -567
  178. package/src/memory/channel-guardian-store.ts +66 -1317
  179. package/src/memory/conflict-store.ts +4 -4
  180. package/src/memory/conversation-attention-store.ts +4 -7
  181. package/src/memory/conversation-crud.ts +668 -0
  182. package/src/memory/conversation-queries.ts +361 -0
  183. package/src/memory/conversation-store.ts +45 -983
  184. package/src/memory/db-connection.ts +3 -0
  185. package/src/memory/db-init.ts +25 -0
  186. package/src/memory/delivery-channels.ts +175 -0
  187. package/src/memory/delivery-crud.ts +211 -0
  188. package/src/memory/delivery-status.ts +199 -0
  189. package/src/memory/embedding-backend.ts +70 -4
  190. package/src/memory/embedding-local.ts +12 -2
  191. package/src/memory/entity-extractor.ts +3 -8
  192. package/src/memory/fts-reconciler.ts +121 -0
  193. package/src/memory/guardian-action-store.ts +366 -3
  194. package/src/memory/guardian-approvals.ts +569 -0
  195. package/src/memory/guardian-bindings.ts +130 -0
  196. package/src/memory/guardian-rate-limits.ts +196 -0
  197. package/src/memory/guardian-verification.ts +520 -0
  198. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  199. package/src/memory/job-utils.ts +8 -5
  200. package/src/memory/jobs-store.ts +66 -6
  201. package/src/memory/jobs-worker.ts +23 -1
  202. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  203. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  204. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  205. package/src/memory/migrations/100-core-tables.ts +1 -1
  206. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  207. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  208. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  209. package/src/memory/migrations/113-late-migrations.ts +1 -1
  210. package/src/memory/migrations/116-messages-fts.ts +13 -0
  211. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  212. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  213. package/src/memory/migrations/index.ts +8 -3
  214. package/src/memory/migrations/validate-migration-state.ts +114 -15
  215. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  216. package/src/memory/retriever.ts +46 -13
  217. package/src/memory/schema-migration.ts +3 -0
  218. package/src/memory/schema.ts +25 -7
  219. package/src/memory/search/semantic.ts +8 -90
  220. package/src/notifications/README.md +1 -1
  221. package/src/notifications/broadcaster.ts +20 -2
  222. package/src/notifications/conversation-pairing.ts +3 -3
  223. package/src/notifications/decision-engine.ts +173 -8
  224. package/src/notifications/deliveries-store.ts +27 -8
  225. package/src/notifications/preferences-store.ts +7 -7
  226. package/src/notifications/thread-candidates.ts +234 -0
  227. package/src/notifications/types.ts +18 -0
  228. package/src/permissions/defaults.ts +11 -1
  229. package/src/permissions/prompter.ts +17 -0
  230. package/src/permissions/trust-store.ts +2 -0
  231. package/src/providers/failover.ts +19 -0
  232. package/src/providers/registry.ts +46 -1
  233. package/src/runtime/approval-message-composer.ts +1 -1
  234. package/src/runtime/channel-guardian-service.ts +15 -3
  235. package/src/runtime/channel-retry-sweep.ts +7 -2
  236. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  237. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  238. package/src/runtime/guardian-action-message-composer.ts +245 -0
  239. package/src/runtime/guardian-outbound-actions.ts +35 -15
  240. package/src/runtime/guardian-verification-templates.ts +15 -9
  241. package/src/runtime/http-errors.ts +93 -0
  242. package/src/runtime/http-server.ts +140 -51
  243. package/src/runtime/http-types.ts +53 -0
  244. package/src/runtime/ingress-service.ts +237 -0
  245. package/src/runtime/middleware/error-handler.ts +4 -3
  246. package/src/runtime/middleware/rate-limiter.ts +160 -0
  247. package/src/runtime/middleware/request-logger.ts +71 -0
  248. package/src/runtime/middleware/twilio-validation.ts +7 -6
  249. package/src/runtime/pending-interactions.ts +12 -0
  250. package/src/runtime/routes/access-request-decision.ts +215 -0
  251. package/src/runtime/routes/app-routes.ts +25 -18
  252. package/src/runtime/routes/approval-routes.ts +18 -47
  253. package/src/runtime/routes/attachment-routes.ts +15 -41
  254. package/src/runtime/routes/call-routes.ts +20 -20
  255. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  256. package/src/runtime/routes/contact-routes.ts +4 -9
  257. package/src/runtime/routes/conversation-attention-routes.ts +5 -4
  258. package/src/runtime/routes/conversation-routes.ts +26 -57
  259. package/src/runtime/routes/debug-routes.ts +71 -0
  260. package/src/runtime/routes/events-routes.ts +3 -2
  261. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  262. package/src/runtime/routes/identity-routes.ts +14 -10
  263. package/src/runtime/routes/inbound-conversation.ts +3 -2
  264. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  265. package/src/runtime/routes/ingress-routes.ts +174 -0
  266. package/src/runtime/routes/integration-routes.ts +82 -20
  267. package/src/runtime/routes/pairing-routes.ts +11 -10
  268. package/src/runtime/routes/secret-routes.ts +10 -18
  269. package/src/runtime/verification-rate-limiter.ts +83 -0
  270. package/src/schedule/schedule-store.ts +13 -1
  271. package/src/schedule/scheduler.ts +2 -2
  272. package/src/security/secret-ingress.ts +5 -2
  273. package/src/security/secret-scanner.ts +72 -6
  274. package/src/subagent/manager.ts +6 -4
  275. package/src/swarm/plan-validator.ts +4 -1
  276. package/src/tasks/task-runner.ts +3 -1
  277. package/src/tools/browser/api-map.ts +9 -6
  278. package/src/tools/calls/call-start.ts +20 -0
  279. package/src/tools/executor.ts +50 -568
  280. package/src/tools/permission-checker.ts +272 -0
  281. package/src/tools/registry.ts +14 -6
  282. package/src/tools/reminder/reminder-store.ts +7 -7
  283. package/src/tools/reminder/reminder.ts +6 -3
  284. package/src/tools/secret-detection-handler.ts +301 -0
  285. package/src/tools/subagent/message.ts +1 -1
  286. package/src/tools/system/voice-config.ts +62 -0
  287. package/src/tools/tasks/index.ts +3 -3
  288. package/src/tools/tasks/work-item-list.ts +3 -3
  289. package/src/tools/tasks/work-item-update.ts +4 -5
  290. package/src/tools/tool-approval-handler.ts +192 -0
  291. package/src/tools/tool-manifest.ts +2 -0
  292. package/src/watcher/watcher-store.ts +9 -9
  293. package/src/work-items/work-item-runner.ts +9 -6
  294. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  295. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // --- In-memory checkpoint store ---
7
+ const store = new Map<string, string>();
8
+
9
+ mock.module('../memory/checkpoints.js', () => ({
10
+ getMemoryCheckpoint: mock((key: string) => store.get(key) ?? null),
11
+ setMemoryCheckpoint: mock((key: string, value: string) => store.set(key, value)),
12
+ }));
13
+
14
+ // --- Temp directory for workspace paths ---
15
+ let tempDir: string;
16
+
17
+ // Mock platform to avoid env-registry transitive imports.
18
+ // All needed exports are stubbed; getWorkspacePromptPath is the only one
19
+ // exercised by update-bulletin.ts.
20
+ mock.module('../util/platform.js', () => ({
21
+ getWorkspacePromptPath: mock((file: string) => join(tempDir, file)),
22
+ getWorkspaceDir: () => tempDir,
23
+ getRootDir: () => tempDir,
24
+ getDataDir: () => join(tempDir, 'data'),
25
+ getPlatformName: () => 'darwin',
26
+ isMacOS: () => false,
27
+ isLinux: () => false,
28
+ isWindows: () => false,
29
+ ensureDataDir: () => {},
30
+ getDbPath: () => '',
31
+ getLogPath: () => '',
32
+ getHistoryPath: () => '',
33
+ getHooksDir: () => '',
34
+ getSocketPath: () => '',
35
+ getSessionTokenPath: () => '',
36
+ getHttpTokenPath: () => '',
37
+ getPlatformTokenPath: () => '',
38
+ getPidPath: () => '',
39
+ getWorkspaceConfigPath: () => '',
40
+ getWorkspaceSkillsDir: () => '',
41
+ getWorkspaceHooksDir: () => '',
42
+ getIpcBlobDir: () => '',
43
+ getSandboxRootDir: () => '',
44
+ getSandboxWorkingDir: () => '',
45
+ getInterfacesDir: () => '',
46
+ getClipboardCommand: () => null,
47
+ readLockfile: () => null,
48
+ normalizeAssistantId: (id: string) => id,
49
+ writeLockfile: () => {},
50
+ readPlatformToken: () => null,
51
+ readSessionToken: () => null,
52
+ readHttpToken: () => null,
53
+ removeSocketFile: () => {},
54
+ getTCPPort: () => 8765,
55
+ isTCPEnabled: () => false,
56
+ getTCPHost: () => '127.0.0.1',
57
+ isIOSPairingEnabled: () => false,
58
+ migrateToDataLayout: () => {},
59
+ migratePath: () => {},
60
+ migrateToWorkspaceLayout: () => {},
61
+ }));
62
+
63
+ // Mock system-prompt to provide only stripCommentLines without pulling in
64
+ // the rest of the system-prompt transitive dependency tree.
65
+ mock.module('../config/system-prompt.js', () => {
66
+ // Inline a minimal implementation of stripCommentLines matching production behavior.
67
+ function stripCommentLines(content: string): string {
68
+ const normalized = content.replace(/\r\n/g, '\n');
69
+ let openFenceChar: string | null = null;
70
+ const filtered = normalized.split('\n').filter((line) => {
71
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
72
+ if (fenceMatch) {
73
+ const char = fenceMatch[1][0];
74
+ if (!openFenceChar) {
75
+ openFenceChar = char;
76
+ } else if (char === openFenceChar) {
77
+ openFenceChar = null;
78
+ }
79
+ }
80
+ if (openFenceChar) return true;
81
+ return !line.trimStart().startsWith('_');
82
+ });
83
+ return filtered
84
+ .join('\n')
85
+ .replace(/\n{3,}/g, '\n\n')
86
+ .trim();
87
+ }
88
+ return { stripCommentLines };
89
+ });
90
+
91
+ mock.module('../version.js', () => ({
92
+ APP_VERSION: '1.0.0',
93
+ }));
94
+
95
+ const { syncUpdateBulletinOnStartup } = await import('../config/update-bulletin.js');
96
+
97
+ describe('syncUpdateBulletinOnStartup', () => {
98
+ beforeEach(() => {
99
+ store.clear();
100
+ tempDir = join(tmpdir(), `update-bulletin-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
101
+ mkdirSync(tempDir, { recursive: true });
102
+ });
103
+
104
+ afterEach(() => {
105
+ rmSync(tempDir, { recursive: true, force: true });
106
+ });
107
+
108
+ it('creates workspace file on first eligible run', () => {
109
+ const workspacePath = join(tempDir, 'UPDATES.md');
110
+ expect(existsSync(workspacePath)).toBe(false);
111
+
112
+ syncUpdateBulletinOnStartup();
113
+
114
+ expect(existsSync(workspacePath)).toBe(true);
115
+ const content = readFileSync(workspacePath, 'utf-8');
116
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
117
+ expect(content).toContain("What's New");
118
+ });
119
+
120
+ it('appends release block when workspace file exists without current marker', () => {
121
+ const workspacePath = join(tempDir, 'UPDATES.md');
122
+ const preExisting = '<!-- vellum-update-release:0.9.0 -->\nOld release notes.\n';
123
+ writeFileSync(workspacePath, preExisting, 'utf-8');
124
+
125
+ syncUpdateBulletinOnStartup();
126
+
127
+ const content = readFileSync(workspacePath, 'utf-8');
128
+ expect(content).toContain('<!-- vellum-update-release:0.9.0 -->');
129
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
130
+ expect(content).toContain('Old release notes.');
131
+ });
132
+
133
+ it('does not duplicate same marker on repeated runs', () => {
134
+ syncUpdateBulletinOnStartup();
135
+ const workspacePath = join(tempDir, 'UPDATES.md');
136
+ const afterFirst = readFileSync(workspacePath, 'utf-8');
137
+
138
+ syncUpdateBulletinOnStartup();
139
+ const afterSecond = readFileSync(workspacePath, 'utf-8');
140
+
141
+ expect(afterSecond).toBe(afterFirst);
142
+ });
143
+
144
+ it('skips completed release', () => {
145
+ store.set('updates:completed_releases', JSON.stringify(['1.0.0']));
146
+ const workspacePath = join(tempDir, 'UPDATES.md');
147
+
148
+ syncUpdateBulletinOnStartup();
149
+
150
+ expect(existsSync(workspacePath)).toBe(false);
151
+ });
152
+
153
+ it('adds current release to active set', () => {
154
+ syncUpdateBulletinOnStartup();
155
+
156
+ const raw = store.get('updates:active_releases');
157
+ expect(raw).toBeDefined();
158
+ const active: string[] = JSON.parse(raw!);
159
+ expect(active).toContain('1.0.0');
160
+ });
161
+
162
+ it('marks active releases as completed when UPDATES.md is deleted', () => {
163
+ // Pre-populate active releases in the store
164
+ store.set('updates:active_releases', JSON.stringify(['0.8.0', '0.9.0']));
165
+
166
+ // Workspace file does not exist — simulates the assistant having deleted it
167
+ const workspacePath = join(tempDir, 'UPDATES.md');
168
+ expect(existsSync(workspacePath)).toBe(false);
169
+
170
+ syncUpdateBulletinOnStartup();
171
+
172
+ // Active set should be cleared (except for the newly-added current release)
173
+ const activeRaw = store.get('updates:active_releases');
174
+ expect(activeRaw).toBeDefined();
175
+ const active: string[] = JSON.parse(activeRaw!);
176
+ // The old releases should not be in the active set
177
+ expect(active).not.toContain('0.8.0');
178
+ expect(active).not.toContain('0.9.0');
179
+
180
+ // The old releases should now be completed
181
+ const completedRaw = store.get('updates:completed_releases');
182
+ expect(completedRaw).toBeDefined();
183
+ const completed: string[] = JSON.parse(completedRaw!);
184
+ expect(completed).toContain('0.8.0');
185
+ expect(completed).toContain('0.9.0');
186
+ });
187
+
188
+ it('does not recreate completed release after deletion', () => {
189
+ // First run — creates the workspace file and marks 1.0.0 active
190
+ syncUpdateBulletinOnStartup();
191
+ const workspacePath = join(tempDir, 'UPDATES.md');
192
+ expect(existsSync(workspacePath)).toBe(true);
193
+
194
+ // Simulate assistant deleting the file to signal completion
195
+ rmSync(workspacePath);
196
+ expect(existsSync(workspacePath)).toBe(false);
197
+
198
+ // Second run — deletion-completion should mark 1.0.0 completed
199
+ syncUpdateBulletinOnStartup();
200
+
201
+ // The file should NOT be recreated since the release is now completed
202
+ expect(existsSync(workspacePath)).toBe(false);
203
+ });
204
+
205
+ it('merges pending old block with new release block', () => {
206
+ const workspacePath = join(tempDir, 'UPDATES.md');
207
+ // Pre-create workspace file with an old release block
208
+ const oldContent =
209
+ '<!-- vellum-update-release:0.9.0 -->\nOld release notes for 0.9.0.\n<!-- /vellum-update-release:0.9.0 -->\n';
210
+ writeFileSync(workspacePath, oldContent, 'utf-8');
211
+
212
+ syncUpdateBulletinOnStartup();
213
+
214
+ const content = readFileSync(workspacePath, 'utf-8');
215
+ // Both old and new release blocks should be present
216
+ expect(content).toContain('<!-- vellum-update-release:0.9.0 -->');
217
+ expect(content).toContain('Old release notes for 0.9.0.');
218
+ expect(content).toContain('<!-- vellum-update-release:1.0.0 -->');
219
+ });
220
+
221
+ it('idempotent on repeated sync calls', () => {
222
+ // First call
223
+ syncUpdateBulletinOnStartup();
224
+ const workspacePath = join(tempDir, 'UPDATES.md');
225
+ const afterFirst = readFileSync(workspacePath, 'utf-8');
226
+
227
+ // Second call
228
+ syncUpdateBulletinOnStartup();
229
+ const afterSecond = readFileSync(workspacePath, 'utf-8');
230
+
231
+ expect(afterSecond).toBe(afterFirst);
232
+
233
+ // Third call for good measure
234
+ syncUpdateBulletinOnStartup();
235
+ const afterThird = readFileSync(workspacePath, 'utf-8');
236
+
237
+ expect(afterThird).toBe(afterFirst);
238
+ });
239
+
240
+ it('write path produces valid UTF-8 with trailing newline', () => {
241
+ syncUpdateBulletinOnStartup();
242
+ const workspacePath = join(tempDir, 'UPDATES.md');
243
+ const content = readFileSync(workspacePath, 'utf-8');
244
+
245
+ expect(content.length).toBeGreaterThan(0);
246
+ expect(content.endsWith('\n')).toBe(true);
247
+
248
+ // Verify round-trip through Buffer produces identical content (valid UTF-8)
249
+ const roundTripped = Buffer.from(content, 'utf-8').toString('utf-8');
250
+ expect(roundTripped).toBe(content);
251
+ });
252
+
253
+ it('no temp file leftovers after successful write', () => {
254
+ syncUpdateBulletinOnStartup();
255
+
256
+ const entries = readdirSync(tempDir);
257
+ const tmpFiles = entries.filter((e) => e.includes('.tmp.'));
258
+ expect(tmpFiles).toHaveLength(0);
259
+ });
260
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Contract test: ensures the bundled UPDATES.md template exists and meets
3
+ * the format expectations that the bulletin system depends on at runtime.
4
+ *
5
+ * The "## What's New" heading is a structural contract — bulletin rendering
6
+ * logic expects this section to be present in the template.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
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 contains non-whitespace content', () => {
21
+ const content = readFileSync(TEMPLATE_PATH, 'utf-8');
22
+ expect(content.trim().length).toBeGreaterThan(0);
23
+ });
24
+
25
+ test('template contains the "## What\'s New" heading', () => {
26
+ const content = readFileSync(TEMPLATE_PATH, 'utf-8');
27
+ expect(content).toContain("## What's New");
28
+ });
29
+ });
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',
@@ -10,6 +10,11 @@
10
10
 
11
11
  import type { ServerMessage } from '../daemon/ipc-contract.js';
12
12
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
13
+ import {
14
+ getDeliveriesByRequestId,
15
+ getPendingRequestByCallSessionId,
16
+ markTimedOutWithReason,
17
+ } from '../memory/guardian-action-store.js';
13
18
  import { getLogger } from '../util/logger.js';
14
19
  import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
15
20
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
@@ -22,7 +27,10 @@ import {
22
27
  recordCallEvent,
23
28
  updateCallSession,
24
29
  } from './call-store.js';
30
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
31
+ import { readHttpToken } from '../util/platform.js';
25
32
  import { dispatchGuardianQuestion } from './guardian-dispatch.js';
33
+ import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
26
34
  import type { RelayConnection } from './relay-server.js';
27
35
  import type { PromptSpeakerContext } from './speaker-identification.js';
28
36
  import { startVoiceTurn, type VoiceTurnHandle } from './voice-session-bridge.js';
@@ -38,6 +46,8 @@ const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
38
46
  const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
39
47
  const CALL_OPENING_ACK_MARKER_REGEX = /\[CALL_OPENING_ACK\]/g;
40
48
  const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
49
+ const GUARDIAN_TIMEOUT_MARKER_REGEX = /\[GUARDIAN_TIMEOUT\]/g;
50
+ const GUARDIAN_UNAVAILABLE_MARKER_REGEX = /\[GUARDIAN_UNAVAILABLE\]/g;
41
51
  const CALL_OPENING_MARKER = '[CALL_OPENING]';
42
52
  const CALL_OPENING_ACK_MARKER = '[CALL_OPENING_ACK]';
43
53
  const END_CALL_MARKER = '[END_CALL]';
@@ -49,7 +59,9 @@ function stripInternalSpeechMarkers(text: string): string {
49
59
  .replace(USER_INSTRUCTION_MARKER_REGEX, '')
50
60
  .replace(CALL_OPENING_MARKER_REGEX, '')
51
61
  .replace(CALL_OPENING_ACK_MARKER_REGEX, '')
52
- .replace(END_CALL_MARKER_REGEX, '');
62
+ .replace(END_CALL_MARKER_REGEX, '')
63
+ .replace(GUARDIAN_TIMEOUT_MARKER_REGEX, '')
64
+ .replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, '');
53
65
  }
54
66
 
55
67
  export class CallController {
@@ -92,6 +104,13 @@ export class CallController {
92
104
  * alternation in the underlying session pipeline.
93
105
  */
94
106
  private lastSentWasOpener = false;
107
+ /**
108
+ * Set to true after a guardian consultation timeout occurs in this call.
109
+ * Subsequent ASK_GUARDIAN attempts skip the full wait and immediately
110
+ * inject a guardian-unavailable instruction so the model can adapt
111
+ * without blocking the caller.
112
+ */
113
+ private guardianUnavailableForCall = false;
95
114
 
96
115
  constructor(
97
116
  callSessionId: string,
@@ -401,6 +420,8 @@ export class CallController {
401
420
  '[CALL_OPENING]'.startsWith(afterBracket) ||
402
421
  '[CALL_OPENING_ACK]'.startsWith(afterBracket) ||
403
422
  '[END_CALL]'.startsWith(afterBracket) ||
423
+ '[GUARDIAN_TIMEOUT]'.startsWith(afterBracket) ||
424
+ '[GUARDIAN_UNAVAILABLE]'.startsWith(afterBracket) ||
404
425
  afterBracket.startsWith('[ASK_GUARDIAN:') ||
405
426
  afterBracket.startsWith('[USER_ANSWERED:') ||
406
427
  afterBracket.startsWith('[USER_INSTRUCTION:') ||
@@ -409,7 +430,11 @@ export class CallController {
409
430
  afterBracket === '[CALL_OPENING_ACK' ||
410
431
  afterBracket.startsWith('[CALL_OPENING_ACK]') ||
411
432
  afterBracket === '[END_CALL' ||
412
- afterBracket.startsWith('[END_CALL]');
433
+ afterBracket.startsWith('[END_CALL]') ||
434
+ afterBracket === '[GUARDIAN_TIMEOUT' ||
435
+ afterBracket.startsWith('[GUARDIAN_TIMEOUT]') ||
436
+ afterBracket === '[GUARDIAN_UNAVAILABLE' ||
437
+ afterBracket.startsWith('[GUARDIAN_UNAVAILABLE]');
413
438
 
414
439
  if (!couldBeControl) {
415
440
  // Not a control marker prefix — flush up to the next '[' (if any)
@@ -514,6 +539,19 @@ export class CallController {
514
539
  log.info({ callSessionId: this.callSessionId }, 'Caller is guardian — skipping ASK_GUARDIAN dispatch, asking directly');
515
540
  this.pendingInstructions.push(`You just tried to use [ASK_GUARDIAN] but the person on the phone IS your guardian. Ask them directly: "${questionText}"`);
516
541
  // Fall through to normal turn completion (idle + flushPendingInstructions)
542
+ } else if (this.guardianUnavailableForCall) {
543
+ // Guardian already timed out earlier in this call — skip the full
544
+ // consultation wait and immediately tell the model to proceed
545
+ // without guardian input.
546
+ log.info({ callSessionId: this.callSessionId }, 'Guardian unavailable for call — skipping ASK_GUARDIAN wait');
547
+ recordCallEvent(this.callSessionId, 'guardian_unavailable_skipped', { question: questionText });
548
+ this.pendingInstructions.push(
549
+ `[GUARDIAN_UNAVAILABLE] You tried to consult your guardian again, but they were already unreachable earlier in this call. `
550
+ + `Do NOT use [ASK_GUARDIAN] again. Instead, let the caller know you cannot reach the guardian right now, `
551
+ + `and continue the conversation by asking if there is anything else you can help with or if they would like a callback. `
552
+ + `The unanswered question was: "${questionText}"`,
553
+ );
554
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
517
555
  } else {
518
556
  const pendingQuestion = createPendingQuestion(this.callSessionId, questionText);
519
557
  this.state = 'waiting_on_user';
@@ -536,39 +574,74 @@ export class CallController {
536
574
 
537
575
  // Set a consultation timeout
538
576
  this.consultationTimer = setTimeout(() => {
539
- if (this.state === 'waiting_on_user') {
540
- log.info({ callSessionId: this.callSessionId }, 'User consultation timed out');
541
- this.relay.sendTextToken(
542
- 'I\'m sorry, I wasn\'t able to get that information in time. Let me move on.',
543
- true,
577
+ if (this.state !== 'waiting_on_user') return;
578
+
579
+ log.info({ callSessionId: this.callSessionId }, 'User consultation timed out');
580
+
581
+ // Mark the linked guardian action request as timed out and
582
+ // send expiry notices to guardian destinations. Deliveries
583
+ // must be captured before markTimedOutWithReason changes
584
+ // their status.
585
+ const pendingActionRequest = getPendingRequestByCallSessionId(this.callSessionId);
586
+ if (pendingActionRequest) {
587
+ const deliveries = getDeliveriesByRequestId(pendingActionRequest.id);
588
+ markTimedOutWithReason(pendingActionRequest.id, 'call_timeout');
589
+ log.info(
590
+ { callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
591
+ 'Marked guardian action request as timed out',
544
592
  );
545
- this.state = 'idle';
546
- updateCallSession(this.callSessionId, { status: 'in_progress' });
547
- expirePendingQuestions(this.callSessionId);
548
- // Restart silence detection now that we're back to idle.
549
- // flushPendingInstructions / drainPendingCallerUtterances will
550
- // reset it again when they start a new turn, but we need the
551
- // fallback in case neither queue has work.
552
- this.resetSilenceTimer();
553
-
554
- const hasInstructions = this.pendingInstructions.length > 0;
555
- const hasUtterances = this.pendingCallerUtterances.length > 0;
556
-
557
- if (hasInstructions && hasUtterances) {
558
- // Merge both queues into a single turn to avoid the race where
559
- // flushPendingInstructions fires a turn that gets aborted by
560
- // drainPendingCallerUtterances, losing the instructions.
561
- const instructionPrefix = this.pendingInstructions
562
- .map((instr) => `[USER_INSTRUCTION: ${instr}]`)
563
- .join('\n');
564
- this.pendingInstructions = [];
565
- this.drainPendingCallerUtterances(instructionPrefix);
566
- } else if (hasInstructions) {
567
- this.flushPendingInstructions();
568
- } else if (hasUtterances) {
569
- this.drainPendingCallerUtterances();
593
+ void sendGuardianExpiryNotices(
594
+ deliveries,
595
+ pendingActionRequest.assistantId,
596
+ getGatewayInternalBaseUrl(),
597
+ readHttpToken() ?? undefined,
598
+ ).catch((err) => {
599
+ log.error(
600
+ { err, callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
601
+ 'Failed to send guardian action expiry notices after call timeout',
602
+ );
603
+ });
604
+ }
605
+
606
+ // Expire pending questions and update call state
607
+ expirePendingQuestions(this.callSessionId);
608
+ this.state = 'idle';
609
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
610
+ this.guardianUnavailableForCall = true;
611
+ recordCallEvent(this.callSessionId, 'guardian_consultation_timed_out', { question: questionText });
612
+
613
+ // Restart silence detection before firing the generated turn
614
+ this.resetSilenceTimer();
615
+
616
+ // Build a generated turn instruction instead of hardcoded text.
617
+ // Merge any queued instructions and caller utterances into the
618
+ // timeout turn to avoid concurrent-turn races.
619
+ const timeoutInstruction =
620
+ `[GUARDIAN_TIMEOUT] Your guardian did not respond in time to your question: "${questionText}". `
621
+ + `Apologize to the caller for the delay, let them know you were unable to reach your guardian, `
622
+ + `ask if they would like to leave a message or receive a callback, `
623
+ + `and ask if there are any other questions you can help with right now.`;
624
+
625
+ const parts: string[] = [];
626
+ for (const instr of this.pendingInstructions) {
627
+ parts.push(`[USER_INSTRUCTION: ${instr}]`);
628
+ }
629
+ this.pendingInstructions = [];
630
+ parts.push(`[USER_INSTRUCTION: ${timeoutInstruction}]`);
631
+
632
+ if (this.pendingCallerUtterances.length > 0) {
633
+ const latest = this.pendingCallerUtterances[this.pendingCallerUtterances.length - 1];
634
+ this.pendingCallerUtterances = [];
635
+ const callerContent = this.formatCallerUtterance(latest.transcript, latest.speaker);
636
+ if (callerContent.length > 0) {
637
+ parts.push(callerContent);
570
638
  }
571
639
  }
640
+
641
+ const content = parts.join('\n');
642
+ this.runTurn(content).catch((err) =>
643
+ log.error({ err, callSessionId: this.callSessionId }, 'runTurn failed after guardian consultation timeout'),
644
+ );
572
645
  }, getUserConsultationTimeoutMs());
573
646
  return;
574
647
  }
@@ -587,7 +660,9 @@ export class CallController {
587
660
 
588
661
  // Notify the voice conversation
589
662
  if (shouldNotifyCompletion && currentSession) {
590
- persistCallCompletionMessage(currentSession.conversationId, this.callSessionId);
663
+ persistCallCompletionMessage(currentSession.conversationId, this.callSessionId).catch((err) => {
664
+ log.error({ err, conversationId: currentSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
665
+ });
591
666
  fireCallCompletionNotifier(currentSession.conversationId, this.callSessionId);
592
667
  }
593
668
 
@@ -596,6 +671,8 @@ export class CallController {
596
671
  const durationMs = currentSession.startedAt ? Date.now() - currentSession.startedAt : 0;
597
672
  addPointerMessage(currentSession.initiatedFromConversationId, 'completed', currentSession.toNumber, {
598
673
  duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
674
+ }).catch((err) => {
675
+ log.warn({ conversationId: currentSession.initiatedFromConversationId, err }, 'Skipping pointer write — origin conversation may no longer exist');
599
676
  });
600
677
  }
601
678
  this.state = 'idle';
@@ -761,7 +838,9 @@ export class CallController {
761
838
  updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
762
839
  recordCallEvent(this.callSessionId, 'call_ended', { reason: 'max_duration' });
763
840
  if (shouldNotifyCompletion && currentSession) {
764
- persistCallCompletionMessage(currentSession.conversationId, this.callSessionId);
841
+ persistCallCompletionMessage(currentSession.conversationId, this.callSessionId).catch((err) => {
842
+ log.error({ err, conversationId: currentSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
843
+ });
765
844
  fireCallCompletionNotifier(currentSession.conversationId, this.callSessionId);
766
845
  }
767
846
 
@@ -770,6 +849,8 @@ export class CallController {
770
849
  const durationMs = currentSession.startedAt ? Date.now() - currentSession.startedAt : 0;
771
850
  addPointerMessage(currentSession.initiatedFromConversationId, 'completed', currentSession.toNumber, {
772
851
  duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
852
+ }).catch((err) => {
853
+ log.warn({ conversationId: currentSession.initiatedFromConversationId, err }, 'Skipping pointer write — origin conversation may no longer exist');
773
854
  });
774
855
  }
775
856
  }, 3000);
@@ -21,9 +21,9 @@ export function buildCallCompletionMessage(callSessionId: string): string {
21
21
  return `**${statusLabel}**${durationStr}. ${events.length} event(s) recorded.`;
22
22
  }
23
23
 
24
- export function persistCallCompletionMessage(conversationId: string, callSessionId: string): string {
24
+ export async function persistCallCompletionMessage(conversationId: string, callSessionId: string): Promise<string> {
25
25
  const summaryText = buildCallCompletionMessage(callSessionId);
26
- conversationStore.addMessage(
26
+ await conversationStore.addMessage(
27
27
  conversationId,
28
28
  'assistant',
29
29
  JSON.stringify([{ type: 'text', text: summaryText }]),
@@ -366,7 +366,9 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
366
366
  log.info({ callSessionId: session.id, callSid }, 'Call initiated successfully');
367
367
 
368
368
  // Post a concise pointer message in the initiating conversation
369
- addPointerMessage(conversationId, 'started', phoneNumber);
369
+ addPointerMessage(conversationId, 'started', phoneNumber).catch((err) => {
370
+ log.warn({ conversationId, err }, 'Failed to post call-started pointer message');
371
+ });
370
372
 
371
373
  return {
372
374
  ok: true,
@@ -392,7 +394,9 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
392
394
  }
393
395
 
394
396
  // Post a failure pointer message in the initiating conversation
395
- addPointerMessage(conversationId, 'failed', phoneNumber, { reason: msg });
397
+ addPointerMessage(conversationId, 'failed', phoneNumber, { reason: msg }).catch((pointerErr) => {
398
+ log.warn({ conversationId, err: pointerErr }, 'Failed to post call-failed pointer message');
399
+ });
396
400
 
397
401
  return { ok: false, error: `Error initiating call: ${msg}`, status: 500 };
398
402
  }
@@ -572,6 +576,8 @@ export type StartGuardianVerificationCallInput = {
572
576
  phoneNumber: string;
573
577
  guardianVerificationSessionId: string;
574
578
  assistantId?: string;
579
+ /** Origin conversation ID so completion/failure pointers can route back. */
580
+ originConversationId?: string;
575
581
  };
576
582
 
577
583
  export type StartGuardianVerificationCallResult =
@@ -588,7 +594,7 @@ export type StartGuardianVerificationCallResult =
588
594
  export async function startGuardianVerificationCall(
589
595
  input: StartGuardianVerificationCallInput,
590
596
  ): Promise<StartGuardianVerificationCallResult> {
591
- const { phoneNumber, guardianVerificationSessionId, assistantId = 'self' } = input;
597
+ const { phoneNumber, guardianVerificationSessionId, assistantId = 'self', originConversationId } = input;
592
598
 
593
599
  if (!phoneNumber || !E164_REGEX.test(phoneNumber)) {
594
600
  return { ok: false, error: 'phone_number must be in E.164 format', status: 400 };
@@ -626,6 +632,7 @@ export async function startGuardianVerificationCall(
626
632
  callMode: 'guardian_verification',
627
633
  guardianVerificationSessionId,
628
634
  assistantId,
635
+ initiatedFromConversationId: originConversationId,
629
636
  });
630
637
  sessionId = session.id;
631
638