@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -7,14 +7,13 @@
7
7
  * 3. Records guardian_action_delivery rows from pipeline delivery results
8
8
  */
9
9
 
10
- import { getActiveBinding } from '../memory/channel-guardian-store.js';
11
10
  import {
12
- countPendingRequestsByCallSessionId,
13
- createGuardianActionDelivery,
14
- createGuardianActionRequest,
15
- getGuardianConversationIdForCallSession,
16
- updateDeliveryStatus,
17
- } from '../memory/guardian-action-store.js';
11
+ createCanonicalGuardianDelivery,
12
+ createCanonicalGuardianRequest,
13
+ listCanonicalGuardianDeliveries,
14
+ listCanonicalGuardianRequests,
15
+ updateCanonicalGuardianDelivery,
16
+ } from '../memory/canonical-guardian-store.js';
18
17
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
19
18
  import type { NotificationDeliveryResult } from '../notifications/types.js';
20
19
  import { getLogger } from '../util/logger.js';
@@ -41,11 +40,10 @@ export interface GuardianDispatchParams {
41
40
 
42
41
  function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
43
42
  if (result.status === 'sent') {
44
- updateDeliveryStatus(deliveryId, 'sent');
43
+ updateCanonicalGuardianDelivery(deliveryId, { status: 'sent' });
45
44
  return;
46
45
  }
47
- const errorMessage = result.errorMessage ?? `Notification delivery status: ${result.status}`;
48
- updateDeliveryStatus(deliveryId, 'failed', errorMessage);
46
+ updateCanonicalGuardianDelivery(deliveryId, { status: 'failed' });
49
47
  }
50
48
 
51
49
  /**
@@ -90,36 +88,53 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
90
88
  try {
91
89
  const expiresAt = Date.now() + getUserConsultationTimeoutMs();
92
90
 
93
- // Create the action request record
94
- const request = createGuardianActionRequest({
95
- assistantId,
96
- kind: 'ask_guardian',
91
+ // Create the canonical guardian request as the primary record.
92
+ const request = createCanonicalGuardianRequest({
93
+ kind: 'pending_question',
94
+ sourceType: 'voice',
97
95
  sourceChannel: 'voice',
98
- sourceConversationId: conversationId,
96
+ conversationId,
99
97
  callSessionId,
100
98
  pendingQuestionId: pendingQuestion.id,
101
99
  questionText: pendingQuestion.questionText,
102
- expiresAt,
103
100
  toolName,
104
101
  inputDigest,
102
+ expiresAt: new Date(expiresAt).toISOString(),
105
103
  });
106
104
 
107
105
  log.info(
108
106
  { requestId: request.id, requestCode: request.requestCode, callSessionId },
109
- 'Created guardian action request',
107
+ 'Created canonical guardian request for voice dispatch',
110
108
  );
111
109
 
112
- // Count how many guardian requests are already pending for this call.
113
- // This count is a candidate-affinity hint: the decision engine uses it
114
- // to prefer reusing an existing thread when multiple questions arise
115
- // in the same call session.
116
- const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
110
+ // Count how many canonical guardian requests are already pending for
111
+ // this call session. Used as a candidate-affinity hint so the decision
112
+ // engine prefers reusing an existing thread.
113
+ const activeGuardianRequestCount = listCanonicalGuardianRequests({
114
+ status: 'pending',
115
+ sourceType: 'voice',
116
+ }).filter(r => r.callSessionId === callSessionId).length;
117
117
 
118
118
  // Look up the vellum conversation used for the first guardian question
119
- // in this call session. When found, pass it as an affinity hint so the
120
- // notification pipeline deterministically routes to the same conversation
121
- // instead of letting the LLM choose a different thread.
122
- const existingGuardianConversationId = getGuardianConversationIdForCallSession(callSessionId);
119
+ // delivery in this call session. When found, pass it as an affinity hint
120
+ // so the notification pipeline deterministically routes to the same
121
+ // conversation instead of letting the LLM choose a different thread.
122
+ // Find earlier canonical requests for this call session and check their
123
+ // deliveries for a vellum destination conversation ID.
124
+ let existingGuardianConversationId: string | null = null;
125
+ const priorRequests = listCanonicalGuardianRequests({
126
+ sourceType: 'voice',
127
+ }).filter(r => r.callSessionId === callSessionId && r.id !== request.id);
128
+ for (const priorReq of priorRequests) {
129
+ const deliveries = listCanonicalGuardianDeliveries(priorReq.id);
130
+ const vellumDelivery = deliveries.find(
131
+ d => d.destinationChannel === 'vellum' && d.destinationConversationId,
132
+ );
133
+ if (vellumDelivery?.destinationConversationId) {
134
+ existingGuardianConversationId = vellumDelivery.destinationConversationId;
135
+ break;
136
+ }
137
+ }
123
138
  const conversationAffinityHint = existingGuardianConversationId
124
139
  ? { vellum: existingGuardianConversationId }
125
140
  : undefined;
@@ -158,7 +173,7 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
158
173
  dedupeKey: `guardian:${request.id}`,
159
174
  onThreadCreated: (info) => {
160
175
  if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
161
- const delivery = createGuardianActionDelivery({
176
+ const delivery = createCanonicalGuardianDelivery({
162
177
  requestId: request.id,
163
178
  destinationChannel: 'vellum',
164
179
  destinationConversationId: info.conversationId,
@@ -167,13 +182,10 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
167
182
  },
168
183
  });
169
184
 
170
- const telegramBinding = getActiveBinding(assistantId, 'telegram');
171
- const smsBinding = getActiveBinding(assistantId, 'sms');
172
-
173
185
  for (const result of signalResult.deliveryResults) {
174
186
  if (result.channel === 'vellum') {
175
187
  if (!vellumDeliveryId) {
176
- const delivery = createGuardianActionDelivery({
188
+ const delivery = createCanonicalGuardianDelivery({
177
189
  requestId: request.id,
178
190
  destinationChannel: 'vellum',
179
191
  destinationConversationId: result.conversationId,
@@ -188,26 +200,20 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
188
200
  continue;
189
201
  }
190
202
 
191
- const binding = result.channel === 'telegram' ? telegramBinding : smsBinding;
192
- const delivery = createGuardianActionDelivery({
203
+ const delivery = createCanonicalGuardianDelivery({
193
204
  requestId: request.id,
194
205
  destinationChannel: result.channel,
195
206
  destinationChatId: result.destination.length > 0 ? result.destination : undefined,
196
- destinationExternalUserId: binding?.guardianExternalUserId,
197
207
  });
198
208
  applyDeliveryStatus(delivery.id, result);
199
209
  }
200
210
 
201
211
  if (!vellumDeliveryId) {
202
- const fallback = createGuardianActionDelivery({
212
+ const fallback = createCanonicalGuardianDelivery({
203
213
  requestId: request.id,
204
214
  destinationChannel: 'vellum',
205
215
  });
206
- updateDeliveryStatus(
207
- fallback.id,
208
- 'failed',
209
- `No vellum delivery result from notification pipeline (${signalResult.reason})`,
210
- );
216
+ updateCanonicalGuardianDelivery(fallback.id, { status: 'failed' });
211
217
  log.warn(
212
218
  { requestId: request.id, reason: signalResult.reason },
213
219
  'Notification pipeline did not produce a vellum delivery result',
@@ -10,21 +10,25 @@ import { randomInt } from 'node:crypto';
10
10
 
11
11
  import type { ServerWebSocket } from 'bun';
12
12
 
13
+ import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
13
14
  import { getConfig } from '../config/loader.js';
14
15
  import * as conversationStore from '../memory/conversation-store.js';
16
+ import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
15
17
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
18
+ import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
19
+ import {
20
+ resolveActorTrust,
21
+ toGuardianRuntimeContextFromTrust,
22
+ } from '../runtime/actor-trust-resolver.js';
16
23
  import {
17
24
  getPendingChallenge,
18
25
  validateAndConsumeChallenge,
19
26
  } from '../runtime/channel-guardian-service.js';
20
- import {
21
- resolveGuardianContext,
22
- toGuardianRuntimeContext,
23
- } from '../runtime/guardian-context-resolver.js';
24
27
  import {
25
28
  composeVerificationVoice,
26
29
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
27
30
  } from '../runtime/guardian-verification-templates.js';
31
+ import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
28
32
  import { parseJsonSafe } from '../util/json.js';
29
33
  import { getLogger } from '../util/logger.js';
30
34
  import { normalizeAssistantId } from '../util/platform.js';
@@ -171,6 +175,12 @@ export class RelayConnection {
171
175
  // Outbound guardian verification state (system calls the guardian)
172
176
  private outboundGuardianVerificationSessionId: string | null = null;
173
177
 
178
+ // Inbound voice invite redemption state
179
+ private inviteRedemptionActive = false;
180
+ private inviteRedemptionAssistantId: string | null = null;
181
+ private inviteRedemptionFromNumber: string | null = null;
182
+ private inviteRedemptionCodeLength = 6;
183
+
174
184
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
175
185
  this.ws = ws;
176
186
  this.callSessionId = callSessionId;
@@ -426,15 +436,13 @@ export class RelayConnection {
426
436
  // calls msg.from is the caller; for outbound calls msg.to is the
427
437
  // recipient (msg.from is the assistant's Twilio number).
428
438
  const otherPartyNumber = isInbound ? msg.from : msg.to;
429
- const initialGuardianContext = toGuardianRuntimeContext(
430
- 'voice',
431
- resolveGuardianContext({
432
- assistantId,
433
- sourceChannel: 'voice',
434
- externalChatId: otherPartyNumber,
435
- senderExternalUserId: otherPartyNumber || undefined,
436
- }),
437
- );
439
+ const initialActorTrust = resolveActorTrust({
440
+ assistantId,
441
+ sourceChannel: 'voice',
442
+ externalChatId: otherPartyNumber,
443
+ senderExternalUserId: otherPartyNumber || undefined,
444
+ });
445
+ const initialGuardianContext = toGuardianRuntimeContextFromTrust(initialActorTrust, otherPartyNumber);
438
446
 
439
447
  const controller = new CallController(this.callSessionId, this, session?.task ?? null, {
440
448
  broadcast: globalBroadcast,
@@ -471,10 +479,185 @@ export class RelayConnection {
471
479
  if (!isInbound && verificationConfig.enabled) {
472
480
  await this.startVerification(session, verificationConfig);
473
481
  } else if (isInbound) {
474
- // For inbound calls, check if there's a pending voice guardian
475
- // challenge that the caller needs to complete before proceeding.
482
+ // ── Trusted-contact ACL enforcement for inbound voice ──
483
+ // Resolve the caller's trust classification before allowing the call
484
+ // to proceed. Guardian and trusted-contact callers pass through;
485
+ // unknown callers are denied with deterministic voice copy and an
486
+ // access request is created for the guardian — unless there is a
487
+ // pending voice guardian challenge, in which case the caller is
488
+ // expected to be unknown (no binding yet) and should enter the
489
+ // verification flow.
490
+ const actorTrust = resolveActorTrust({
491
+ assistantId,
492
+ sourceChannel: 'voice',
493
+ externalChatId: msg.from,
494
+ senderExternalUserId: msg.from || undefined,
495
+ });
496
+
497
+ // Check for a pending voice guardian challenge before the ACL deny
498
+ // gate. An unknown caller with a pending challenge is expected —
499
+ // they need to complete verification to establish a binding.
476
500
  const pendingChallenge = getPendingChallenge(assistantId, 'voice');
477
501
 
502
+ if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
503
+ // Before denying, check if there is an active voice invite bound
504
+ // to the caller's phone number. If so, enter the invite redemption
505
+ // subflow instead of denying the call outright.
506
+ // Gated behind the voice-invite-redemption feature flag (defaults OFF).
507
+ const voiceInviteEnabled = isAssistantFeatureFlagEnabled(
508
+ 'feature_flags.voice-invite-redemption.enabled',
509
+ config,
510
+ );
511
+
512
+ if (voiceInviteEnabled) {
513
+ let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
514
+ try {
515
+ voiceInvites = findActiveVoiceInvites({
516
+ assistantId,
517
+ expectedExternalUserId: msg.from,
518
+ });
519
+ } catch (err) {
520
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
521
+ }
522
+
523
+ // Exclude invites that are past their expiresAt even if the DB
524
+ // status hasn't been lazily flipped to 'expired' yet.
525
+ const now = Date.now();
526
+ const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
527
+
528
+ if (nonExpiredInvites.length > 0) {
529
+ log.info(
530
+ { callSessionId: this.callSessionId, from: msg.from },
531
+ 'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
532
+ );
533
+ this.startInviteRedemption(assistantId, msg.from);
534
+ return;
535
+ }
536
+ }
537
+
538
+ log.info(
539
+ { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
540
+ 'Inbound voice ACL: unknown caller denied',
541
+ );
542
+
543
+ recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
544
+ from: msg.from,
545
+ trustClass: actorTrust.trustClass,
546
+ denialReason: actorTrust.denialReason,
547
+ });
548
+
549
+ // For revoked/pending members, notify the guardian so they can
550
+ // re-approve. Blocked members are intentionally excluded — the
551
+ // guardian already made an explicit decision to block them.
552
+ let guardianNotified = false;
553
+ if (actorTrust.memberRecord?.status !== 'blocked') {
554
+ try {
555
+ const accessResult = notifyGuardianOfAccessRequest({
556
+ canonicalAssistantId: assistantId,
557
+ sourceChannel: 'voice',
558
+ externalChatId: msg.from,
559
+ senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
560
+ });
561
+ guardianNotified = accessResult.notified;
562
+ } catch (err) {
563
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
564
+ }
565
+ }
566
+
567
+ // Deny with deterministic voice copy and end the call.
568
+ // Mark as disconnecting so handlePrompt ignores caller input
569
+ // during the delay before the session ends.
570
+ const denialMessage = guardianNotified
571
+ ? 'This number is not authorized. Your request has been forwarded to the account guardian.'
572
+ : 'This number is not authorized to use this assistant.';
573
+ this.sendTextToken(denialMessage, true);
574
+
575
+ this.connectionState = 'disconnecting';
576
+
577
+ updateCallSession(this.callSessionId, {
578
+ status: 'failed',
579
+ endedAt: Date.now(),
580
+ lastError: 'Inbound voice ACL: caller not authorized',
581
+ });
582
+
583
+ setTimeout(() => {
584
+ this.endSession('Inbound voice ACL denied');
585
+ }, 3000);
586
+ return;
587
+ }
588
+
589
+ // Members with policy: 'deny' have status: 'active' so resolveActorTrust
590
+ // classifies them as trusted_contact, but the guardian has explicitly
591
+ // denied their access. Block them the same way the text-channel path does.
592
+ if (actorTrust.memberRecord?.policy === 'deny') {
593
+ log.info(
594
+ { callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
595
+ 'Inbound voice ACL: member policy deny',
596
+ );
597
+
598
+ recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
599
+ from: msg.from,
600
+ trustClass: actorTrust.trustClass,
601
+ memberId: actorTrust.memberRecord.id,
602
+ memberPolicy: actorTrust.memberRecord.policy,
603
+ });
604
+
605
+ this.sendTextToken('This number is not authorized to use this assistant.', true);
606
+
607
+ this.connectionState = 'disconnecting';
608
+
609
+ updateCallSession(this.callSessionId, {
610
+ status: 'failed',
611
+ endedAt: Date.now(),
612
+ lastError: 'Inbound voice ACL: member policy deny',
613
+ });
614
+
615
+ setTimeout(() => {
616
+ this.endSession('Inbound voice ACL: member policy deny');
617
+ }, 3000);
618
+ return;
619
+ }
620
+
621
+ // Members with policy: 'escalate' require guardian approval, but a live
622
+ // voice call cannot be paused for async approval. Fail-closed by denying
623
+ // the call with an appropriate message — mirrors the deny block above.
624
+ if (actorTrust.memberRecord?.policy === 'escalate') {
625
+ log.info(
626
+ { callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
627
+ 'Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval',
628
+ );
629
+
630
+ recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
631
+ from: msg.from,
632
+ trustClass: actorTrust.trustClass,
633
+ memberId: actorTrust.memberRecord.id,
634
+ memberPolicy: actorTrust.memberRecord.policy,
635
+ });
636
+
637
+ this.sendTextToken('This number requires guardian approval for calls. Please have the account guardian update your permissions.', true);
638
+
639
+ this.connectionState = 'disconnecting';
640
+
641
+ updateCallSession(this.callSessionId, {
642
+ status: 'failed',
643
+ endedAt: Date.now(),
644
+ lastError: 'Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval',
645
+ });
646
+
647
+ setTimeout(() => {
648
+ this.endSession('Inbound voice ACL: member policy escalate');
649
+ }, 3000);
650
+ return;
651
+ }
652
+
653
+ // Guardian and trusted-contact callers proceed normally.
654
+ // Update the controller's guardian context with the trust-resolved
655
+ // context so downstream policy gates have accurate actor metadata.
656
+ if (this.controller && actorTrust.trustClass !== 'unknown') {
657
+ const resolvedGuardianContext = toGuardianRuntimeContextFromTrust(actorTrust, msg.from);
658
+ this.controller.setGuardianContext(resolvedGuardianContext);
659
+ }
660
+
478
661
  if (pendingChallenge) {
479
662
  this.startInboundGuardianVerification(assistantId, msg.from);
480
663
  } else {
@@ -731,16 +914,14 @@ export class RelayConnection {
731
914
  } else {
732
915
  // Inbound: proceed to normal call flow
733
916
  if (this.controller) {
917
+ const verifiedActorTrust = resolveActorTrust({
918
+ assistantId: this.guardianChallengeAssistantId,
919
+ sourceChannel: 'voice',
920
+ externalChatId: this.guardianVerificationFromNumber,
921
+ senderExternalUserId: this.guardianVerificationFromNumber,
922
+ });
734
923
  this.controller.setGuardianContext(
735
- toGuardianRuntimeContext(
736
- 'voice',
737
- resolveGuardianContext({
738
- assistantId: this.guardianChallengeAssistantId,
739
- sourceChannel: 'voice',
740
- externalChatId: this.guardianVerificationFromNumber,
741
- senderExternalUserId: this.guardianVerificationFromNumber,
742
- }),
743
- ),
924
+ toGuardianRuntimeContextFromTrust(verifiedActorTrust, this.guardianVerificationFromNumber),
744
925
  );
745
926
  this.startNormalCallFlow(this.controller, true);
746
927
  }
@@ -815,6 +996,126 @@ export class RelayConnection {
815
996
  }
816
997
  }
817
998
 
999
+ /**
1000
+ * Enter the invite redemption subflow for an inbound unknown caller
1001
+ * who has an active voice invite. Prompts the caller to enter their
1002
+ * invite code via DTMF or speech.
1003
+ */
1004
+ private startInviteRedemption(assistantId: string, fromNumber: string): void {
1005
+ this.inviteRedemptionActive = true;
1006
+ this.inviteRedemptionAssistantId = assistantId;
1007
+ this.inviteRedemptionFromNumber = fromNumber;
1008
+ this.connectionState = 'verification_pending';
1009
+ this.verificationAttempts = 0;
1010
+ this.verificationMaxAttempts = 3;
1011
+ this.inviteRedemptionCodeLength = 6;
1012
+ this.dtmfBuffer = '';
1013
+
1014
+ recordCallEvent(this.callSessionId, 'invite_redemption_started', {
1015
+ assistantId,
1016
+ codeLength: 6,
1017
+ maxAttempts: this.verificationMaxAttempts,
1018
+ });
1019
+
1020
+ this.sendTextToken(
1021
+ 'Please enter your 6-digit invite code using your keypad, or speak the digits now.',
1022
+ true,
1023
+ );
1024
+
1025
+ log.info(
1026
+ { callSessionId: this.callSessionId, assistantId },
1027
+ 'Inbound voice invite redemption started',
1028
+ );
1029
+ }
1030
+
1031
+ /**
1032
+ * Validate an entered invite code against active voice invites for the
1033
+ * caller. On success, create/activate the ingress member and transition
1034
+ * to the normal call flow. On failure, allow retries up to max attempts.
1035
+ */
1036
+ private attemptInviteCodeRedemption(enteredCode: string): void {
1037
+ if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1038
+ return;
1039
+ }
1040
+
1041
+ const result = redeemVoiceInviteCode({
1042
+ assistantId: this.inviteRedemptionAssistantId,
1043
+ callerExternalUserId: this.inviteRedemptionFromNumber,
1044
+ sourceChannel: 'voice',
1045
+ code: enteredCode,
1046
+ });
1047
+
1048
+ if (result.ok) {
1049
+ this.connectionState = 'connected';
1050
+ this.inviteRedemptionActive = false;
1051
+ this.verificationAttempts = 0;
1052
+ this.dtmfBuffer = '';
1053
+
1054
+ recordCallEvent(this.callSessionId, 'invite_redemption_succeeded', {
1055
+ memberId: result.memberId,
1056
+ ...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
1057
+ });
1058
+ log.info(
1059
+ { callSessionId: this.callSessionId, memberId: result.memberId, type: result.type },
1060
+ 'Voice invite redemption succeeded',
1061
+ );
1062
+
1063
+ if (this.controller) {
1064
+ const redeemedActorTrust = resolveActorTrust({
1065
+ assistantId: this.inviteRedemptionAssistantId,
1066
+ sourceChannel: 'voice',
1067
+ externalChatId: this.inviteRedemptionFromNumber,
1068
+ senderExternalUserId: this.inviteRedemptionFromNumber,
1069
+ });
1070
+ this.controller.setGuardianContext(
1071
+ toGuardianRuntimeContextFromTrust(redeemedActorTrust, this.inviteRedemptionFromNumber),
1072
+ );
1073
+ this.startNormalCallFlow(this.controller, true);
1074
+ }
1075
+ } else {
1076
+ this.verificationAttempts++;
1077
+
1078
+ if (this.verificationAttempts >= this.verificationMaxAttempts) {
1079
+ this.inviteRedemptionActive = false;
1080
+
1081
+ recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
1082
+ attempts: this.verificationAttempts,
1083
+ });
1084
+ log.warn(
1085
+ { callSessionId: this.callSessionId, attempts: this.verificationAttempts },
1086
+ 'Voice invite redemption failed — max attempts reached',
1087
+ );
1088
+
1089
+ this.sendTextToken('Too many invalid attempts. Goodbye.', true);
1090
+
1091
+ updateCallSession(this.callSessionId, {
1092
+ status: 'failed',
1093
+ endedAt: Date.now(),
1094
+ lastError: 'Voice invite redemption failed — max attempts exceeded',
1095
+ });
1096
+
1097
+ const failSession = getCallSession(this.callSessionId);
1098
+ if (failSession) {
1099
+ expirePendingQuestions(this.callSessionId);
1100
+ persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
1101
+ log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
1102
+ });
1103
+ fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
1104
+ }
1105
+
1106
+ setTimeout(() => {
1107
+ this.endSession('Invite redemption failed');
1108
+ }, 2000);
1109
+ } else {
1110
+ log.info(
1111
+ { callSessionId: this.callSessionId, attempt: this.verificationAttempts, maxAttempts: this.verificationMaxAttempts },
1112
+ 'Voice invite redemption attempt failed — retrying',
1113
+ );
1114
+ this.sendTextToken('Invalid code. Please try again.', true);
1115
+ }
1116
+ }
1117
+ }
1118
+
818
1119
  private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
819
1120
  if (this.connectionState === 'disconnecting') {
820
1121
  return;
@@ -845,6 +1146,26 @@ export class RelayConnection {
845
1146
  return;
846
1147
  }
847
1148
 
1149
+ // During invite redemption, attempt to parse spoken digits from the
1150
+ // transcript and validate against the caller's active voice invite.
1151
+ if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
1152
+ const spokenDigits = RelayConnection.parseDigitsFromSpeech(msg.voicePrompt);
1153
+ log.info(
1154
+ { callSessionId: this.callSessionId, transcript: msg.voicePrompt, spokenDigits },
1155
+ 'Speech received during invite redemption',
1156
+ );
1157
+ if (spokenDigits.length >= this.inviteRedemptionCodeLength) {
1158
+ const enteredCode = spokenDigits.slice(0, this.inviteRedemptionCodeLength);
1159
+ this.attemptInviteCodeRedemption(enteredCode);
1160
+ } else if (spokenDigits.length > 0) {
1161
+ this.sendTextToken(
1162
+ `I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
1163
+ true,
1164
+ );
1165
+ }
1166
+ return;
1167
+ }
1168
+
848
1169
  // During outbound callee verification, ignore voice prompts — the callee
849
1170
  // should be entering DTMF digits, not speaking.
850
1171
  if (this.connectionState === 'verification_pending') {
@@ -957,6 +1278,19 @@ export class RelayConnection {
957
1278
  return;
958
1279
  }
959
1280
 
1281
+ // If invite redemption is pending, accumulate digits and validate
1282
+ // the code against the caller's active voice invite.
1283
+ if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
1284
+ this.dtmfBuffer += msg.digit;
1285
+
1286
+ if (this.dtmfBuffer.length >= this.inviteRedemptionCodeLength) {
1287
+ const enteredCode = this.dtmfBuffer.slice(0, this.inviteRedemptionCodeLength);
1288
+ this.dtmfBuffer = '';
1289
+ this.attemptInviteCodeRedemption(enteredCode);
1290
+ }
1291
+ return;
1292
+ }
1293
+
960
1294
  // If outbound callee verification is pending, accumulate digits and check the code
961
1295
  if (this.connectionState === 'verification_pending' && this.verificationCode) {
962
1296
  this.dtmfBuffer += msg.digit;
@@ -1,5 +1,5 @@
1
1
  export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
- export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced';
2
+ export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed';
3
3
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
4
 
5
5
  /**
@@ -250,8 +250,8 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
250
250
  // - guardian: permission prompts auto-allow (parity with guardian chat)
251
251
  // - everyone else (including unknown): fail-closed strict side-effects
252
252
  // with auto-deny confirmations.
253
- const actorRole = opts.guardianContext?.actorRole;
254
- const isGuardian = actorRole === 'guardian';
253
+ const trustClass = opts.guardianContext?.trustClass;
254
+ const isGuardian = trustClass === 'guardian';
255
255
  const forceStrictSideEffects = isGuardian ? undefined : true;
256
256
 
257
257
  // Replace the [CALL_OPENING] marker with a neutral instruction before
@@ -264,7 +264,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
264
264
 
265
265
  // Build the call-control protocol prompt so the model knows how to emit
266
266
  // control markers (ASK_GUARDIAN, END_CALL, etc.) and recognize opener turns.
267
- const isCallerGuardian = opts.guardianContext?.actorRole === 'guardian';
267
+ const isCallerGuardian = opts.guardianContext?.trustClass === 'guardian';
268
268
 
269
269
  const voiceCallControlPrompt = buildVoiceCallControlPrompt({
270
270
  isInbound: opts.isInbound,
package/src/cli.ts CHANGED
@@ -492,6 +492,18 @@ export async function startCli(): Promise<void> {
492
492
  break;
493
493
  }
494
494
 
495
+ case 'message_request_complete': {
496
+ // Request-level terminal for inline approval consumption.
497
+ // When no agent turn remains active, clear busy state and re-prompt.
498
+ if (msg.runStillActive !== true) {
499
+ spinner.stop();
500
+ generating = false;
501
+ process.stdout.write('\n\n');
502
+ prompt();
503
+ }
504
+ break;
505
+ }
506
+
495
507
  case 'generation_handoff': {
496
508
  // The current request's generation is done; show usage and re-prompt.
497
509
  // Always clear `generating` — this CLI client's generation is finished