@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -351,7 +351,7 @@ describe('call-controller', () => {
351
351
 
352
352
  // ── ASK_GUARDIAN pattern ──────────────────────────────────────────
353
353
 
354
- test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
354
+ test('ASK_GUARDIAN pattern: detects pattern, creates pending question, sets session to waiting_on_user', async () => {
355
355
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
356
356
  ['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]'],
357
357
  ));
@@ -365,10 +365,18 @@ describe('call-controller', () => {
365
365
  expect(question!.questionText).toBe('What date works best?');
366
366
  expect(question!.status).toBe('pending');
367
367
 
368
- // Verify session status was updated to waiting_on_user
368
+ // Controller state returns to idle (non-blocking); consultation is
369
+ // tracked separately via pendingConsultation.
370
+ expect(controller.getState()).toBe('idle');
371
+
372
+ // Session status in the store is still set to waiting_on_user for
373
+ // external consumers (e.g. the answer route).
369
374
  const updatedSession = getCallSession(session.id);
370
375
  expect(updatedSession!.status).toBe('waiting_on_user');
371
376
 
377
+ // A pending consultation should be active
378
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
379
+
372
380
  // The ASK_GUARDIAN marker text should NOT appear in the relay tokens
373
381
  const allText = relay.sentTokens.map((t) => t.token).join('');
374
382
  expect(allText).not.toContain('[ASK_GUARDIAN:');
@@ -474,13 +482,14 @@ describe('call-controller', () => {
474
482
 
475
483
  await controller.handleCallerUtterance('Can I book for 7:30?');
476
484
 
477
- // Verify we're in waiting_on_user state
478
- expect(controller.getState()).toBe('waiting_on_user');
485
+ // Controller returns to idle (non-blocking); consultation tracked separately
486
+ expect(controller.getState()).toBe('idle');
487
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
479
488
  const question = getPendingQuestion(session.id);
480
489
  expect(question).not.toBeNull();
481
490
  expect(question!.questionText).toBe('Is 8:00 okay instead?');
482
491
 
483
- // Verify session status
492
+ // Session status in store reflects consultation state
484
493
  const midSession = getCallSession(session.id);
485
494
  expect(midSession!.status).toBe('waiting_on_user');
486
495
 
@@ -530,10 +539,10 @@ describe('call-controller', () => {
530
539
  controller.destroy();
531
540
  });
532
541
 
533
- test('handleUserAnswer: returns false when not in waiting_on_user state', async () => {
542
+ test('handleUserAnswer: returns false when no pending consultation exists', async () => {
534
543
  const { controller } = setupController();
535
544
 
536
- // Controller starts in idle state
545
+ // No consultation is pending — answer should be rejected
537
546
  const result = await controller.handleUserAnswer('some answer');
538
547
  expect(result).toBe(false);
539
548
 
@@ -818,55 +827,54 @@ describe('call-controller', () => {
818
827
  controller.destroy();
819
828
  });
820
829
 
821
- // ── waiting_on_user re-entry guard ────────────────────────────────
830
+ // ── Non-blocking consultation: caller follow-up during pending consultation ──
822
831
 
823
- test('handleCallerUtterance: does NOT trigger startVoiceTurn when waiting_on_user', async () => {
824
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
832
+ test('handleCallerUtterance: triggers normal turn while consultation is pending (non-blocking)', async () => {
833
+ // Trigger ASK_GUARDIAN to start a consultation
825
834
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
826
835
  ['Hold on. [ASK_GUARDIAN: What time works?]'],
827
836
  ));
828
837
  const { controller } = setupController();
829
838
  await controller.handleCallerUtterance('Book me in');
830
- expect(controller.getState()).toBe('waiting_on_user');
839
+ // Controller returns to idle; consultation tracked separately
840
+ expect(controller.getState()).toBe('idle');
841
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
831
842
 
832
843
  // Track calls to startVoiceTurn from this point
833
844
  let turnCallCount = 0;
834
845
  mockStartVoiceTurn.mockImplementation(async (opts: { onTextDelta: (t: string) => void; onComplete: () => void }) => {
835
846
  turnCallCount++;
836
- opts.onTextDelta('Should not appear.');
847
+ opts.onTextDelta('Sure, let me help with that.');
837
848
  opts.onComplete();
838
- return { turnId: 'run-blocked', abort: () => {} };
849
+ return { turnId: 'run-followup', abort: () => {} };
839
850
  });
840
851
 
841
- // Caller speaks while waiting — should be queued, not processed
852
+ // Caller speaks while consultation is pending — should trigger a normal turn
842
853
  await controller.handleCallerUtterance('Hello? Are you still there?');
843
- expect(turnCallCount).toBe(0);
844
- expect(controller.getState()).toBe('waiting_on_user');
854
+ expect(turnCallCount).toBe(1);
855
+ // Controller returns to idle after the turn completes
856
+ expect(controller.getState()).toBe('idle');
857
+ // Consultation should still be pending
858
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
845
859
 
846
860
  controller.destroy();
847
861
  });
848
862
 
849
- test('queued caller utterance IS processed after handleUserAnswer resolves', async () => {
850
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
863
+ test('guardian answer arriving while controller idle: queued as instruction and flushed immediately', async () => {
864
+ // Trigger ASK_GUARDIAN to start a consultation
851
865
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
852
866
  ['Checking. [ASK_GUARDIAN: Confirm appointment?]'],
853
867
  ));
854
868
  const { controller } = setupController();
855
869
  await controller.handleCallerUtterance('I want to schedule');
856
- expect(controller.getState()).toBe('waiting_on_user');
857
-
858
- // Caller speaks while waiting — queued
859
- await controller.handleCallerUtterance('Actually make it 4pm');
870
+ expect(controller.getState()).toBe('idle');
871
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
860
872
 
861
- // Set up mocks for the answer turn and subsequent queued utterance turn
873
+ // Set up mock for the answer turn
862
874
  const turnContents: string[] = [];
863
875
  mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
864
876
  turnContents.push(opts.content);
865
- if (opts.content.includes('[USER_ANSWERED:')) {
866
- opts.onTextDelta('Confirmed.');
867
- } else {
868
- opts.onTextDelta('Got it, 4pm.');
869
- }
877
+ opts.onTextDelta('Confirmed.');
870
878
  opts.onComplete();
871
879
  return { turnId: `run-${turnContents.length}`, abort: () => {} };
872
880
  });
@@ -877,62 +885,60 @@ describe('call-controller', () => {
877
885
  // Give fire-and-forget turns time to complete
878
886
  await new Promise((r) => setTimeout(r, 100));
879
887
 
880
- // The answer turn should have fired
888
+ // The answer turn should have fired with the USER_ANSWERED marker
881
889
  expect(turnContents.some((c) => c.includes('[USER_ANSWERED: Yes, confirmed]'))).toBe(true);
882
- // The queued caller utterance should also have been processed
883
- expect(turnContents.some((c) => c.includes('Actually make it 4pm'))).toBe(true);
890
+ // Consultation should now be cleared
891
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
884
892
 
885
893
  controller.destroy();
886
894
  });
887
895
 
888
- test('no duplicate guardian dispatch: subsequent handleCallerUtterance during waiting_on_user does not produce another ASK_GUARDIAN', async () => {
889
- // Trigger ASK_GUARDIAN to enter waiting_on_user
896
+ test('no duplicate guardian dispatch: repeated informational ASK_GUARDIAN coalesces with existing consultation', async () => {
897
+ // Trigger ASK_GUARDIAN to start first consultation
890
898
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
891
899
  ['Let me ask. [ASK_GUARDIAN: Preferred date?]'],
892
900
  ));
893
- const { controller } = setupController();
901
+ const { session, controller } = setupController();
894
902
  await controller.handleCallerUtterance('Schedule please');
895
- expect(controller.getState()).toBe('waiting_on_user');
896
-
897
- // Count how many times startVoiceTurn is invoked after this point
898
- let postGuardianTurnCount = 0;
899
- mockStartVoiceTurn.mockImplementation(async (opts: { onTextDelta: (t: string) => void; onComplete: () => void }) => {
900
- postGuardianTurnCount++;
901
- // Simulate the model trying to emit another ASK_GUARDIAN
902
- opts.onTextDelta('[ASK_GUARDIAN: Preferred date again?]');
903
- opts.onComplete();
904
- return { turnId: 'run-dup', abort: () => {} };
905
- });
903
+ expect(controller.getState()).toBe('idle');
904
+ const firstQuestionId = controller.getPendingConsultationQuestionId();
905
+ expect(firstQuestionId).not.toBeNull();
906
906
 
907
- // Multiple caller utterances during waiting_on_user all should be queued
907
+ // Model emits another informational ASK_GUARDIAN in a subsequent turn
908
+ // should coalesce (same tool scope: both lack tool metadata)
909
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
910
+ ['Actually let me re-check. [ASK_GUARDIAN: Preferred date again?]'],
911
+ ));
908
912
  await controller.handleCallerUtterance('Hello?');
909
- await controller.handleCallerUtterance('Anyone there?');
910
913
 
911
- // No turns should have started
912
- expect(postGuardianTurnCount).toBe(0);
913
- // State should still be waiting_on_user
914
- expect(controller.getState()).toBe('waiting_on_user');
914
+ // Consultation should be coalesced — same question ID retained
915
+ const secondQuestionId = controller.getPendingConsultationQuestionId();
916
+ expect(secondQuestionId).not.toBeNull();
917
+ expect(secondQuestionId).toBe(firstQuestionId);
918
+
919
+ // The session status should still be waiting_on_user
920
+ const updatedSession = getCallSession(session.id);
921
+ expect(updatedSession!.status).toBe('waiting_on_user');
915
922
 
916
923
  controller.destroy();
917
924
  });
918
925
 
919
- test('handleUserAnswer: returns false when not in waiting_on_user state (stale/duplicate guard)', async () => {
926
+ test('handleUserAnswer: returns false when no pending consultation (stale/duplicate guard)', async () => {
920
927
  const { controller } = setupController();
921
928
 
922
- // idle state
929
+ // No consultation pending — idle state, answer rejected
923
930
  expect(await controller.handleUserAnswer('some answer')).toBe(false);
924
931
 
925
- // processing state — trigger a turn first
932
+ // Start a turn to enter processing state — still no consultation
926
933
  mockStartVoiceTurn.mockImplementation(async (opts: { onTextDelta: (t: string) => void; onComplete: () => void }) => {
927
- // Slow turn — give time to call handleUserAnswer while processing
928
934
  await new Promise((r) => setTimeout(r, 200));
929
935
  opts.onTextDelta('Response.');
930
936
  opts.onComplete();
931
937
  return { turnId: 'run-proc', abort: () => {} };
932
938
  });
933
939
  const turnPromise = controller.handleCallerUtterance('Test');
934
- // Give it a moment to enter processing state
935
940
  await new Promise((r) => setTimeout(r, 10));
941
+ // No consultation → answer rejected regardless of controller state
936
942
  expect(await controller.handleUserAnswer('stale answer')).toBe(false);
937
943
 
938
944
  // Clean up
@@ -940,52 +946,98 @@ describe('call-controller', () => {
940
946
  controller.destroy();
941
947
  });
942
948
 
943
- test('handleUserInstruction: does not trigger turn when controller is not idle', async () => {
944
- // First, trigger ASK_GUARDIAN so controller enters waiting_on_user
949
+ test('duplicate answer to same consultation: first accepted, second rejected', async () => {
950
+ // Trigger ASK_GUARDIAN consultation
945
951
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
946
952
  ['Hold on. [ASK_GUARDIAN: What time?]'],
947
953
  ));
954
+ const { controller } = setupController();
955
+ await controller.handleCallerUtterance('Book me');
956
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
948
957
 
949
- const { session, controller } = setupController();
950
- await controller.handleCallerUtterance('I need an appointment');
951
- expect(controller.getState()).toBe('waiting_on_user');
958
+ // Set up mock for the answer turn
959
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
960
+ opts.onTextDelta('Got it.');
961
+ opts.onComplete();
962
+ return { turnId: 'run-answer', abort: () => {} };
963
+ });
952
964
 
953
- // Track how many times startVoiceTurn is called
954
- let turnCallCount = 0;
955
- mockStartVoiceTurn.mockImplementation(async (opts: { onTextDelta: (t: string) => void; onComplete: () => void }) => {
956
- turnCallCount++;
957
- opts.onTextDelta('Response after instruction.');
965
+ // First answer is accepted
966
+ const first = await controller.handleUserAnswer('3pm');
967
+ expect(first).toBe(true);
968
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
969
+
970
+ // Second answer is rejected — consultation already consumed
971
+ const second = await controller.handleUserAnswer('4pm');
972
+ expect(second).toBe(false);
973
+
974
+ await new Promise((r) => setTimeout(r, 50));
975
+ controller.destroy();
976
+ });
977
+
978
+ test('handleUserInstruction: queues when processing, but triggers when idle', async () => {
979
+ // Track content passed to each voice turn invocation
980
+ const turnContents: string[] = [];
981
+ let turnCount = 0;
982
+
983
+ // Start a slow turn to put controller in processing/speaking state.
984
+ // After the first turn completes, the mock switches to a fast handler
985
+ // that captures content so we can verify the flushed instruction.
986
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
987
+ turnCount++;
988
+ if (turnCount === 1) {
989
+ // First turn: slow, simulates processing state
990
+ await new Promise((r) => setTimeout(r, 100));
991
+ opts.onTextDelta('Response.');
992
+ opts.onComplete();
993
+ return { turnId: 'run-1', abort: () => {} };
994
+ }
995
+ // Subsequent turns: capture content and complete immediately
996
+ turnContents.push(opts.content);
997
+ opts.onTextDelta('Noted.');
958
998
  opts.onComplete();
959
- return { turnId: 'run-2', abort: () => {} };
999
+ return { turnId: `run-${turnCount}`, abort: () => {} };
960
1000
  });
961
1001
 
962
- // Inject instruction while in waiting_on_user state
963
- await controller.handleUserInstruction('Suggest morning slots');
1002
+ const { session, controller } = setupController();
1003
+ const turnPromise = controller.handleCallerUtterance('Hello');
1004
+ await new Promise((r) => setTimeout(r, 10));
964
1005
 
965
- // The turn should NOT have been triggered since we're not idle
966
- expect(turnCallCount).toBe(0);
1006
+ // Inject instruction while processing should be queued
1007
+ await controller.handleUserInstruction('Suggest morning slots');
967
1008
 
968
- // But the event should still be recorded
1009
+ // Event should be recorded even when queued
969
1010
  const events = getCallEvents(session.id);
970
1011
  const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
971
1012
  expect(instructionEvents.length).toBe(1);
972
1013
 
1014
+ // Wait for the first turn to finish (instructions flushed at turn boundary)
1015
+ await turnPromise;
1016
+
1017
+ // Allow the fire-and-forget flush turn to execute
1018
+ await new Promise((r) => setTimeout(r, 50));
1019
+
1020
+ // The queued instruction should have been flushed into a new turn
1021
+ expect(turnContents.length).toBeGreaterThanOrEqual(1);
1022
+ expect(turnContents.some((c) => c.includes('[USER_INSTRUCTION: Suggest morning slots]'))).toBe(true);
1023
+
1024
+ // Controller should return to idle after the flush turn completes
1025
+ expect(controller.getState()).toBe('idle');
1026
+
973
1027
  controller.destroy();
974
1028
  });
975
1029
 
976
1030
  // ── Post-end-call drain guard ───────────────────────────────────
977
1031
 
978
- test('handleUserAnswer: queued caller utterances are discarded (not processed) when answer turn ends the call', async () => {
979
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
1032
+ test('handleUserAnswer: answer turn ends call with END_CALL, no further turns after completion', async () => {
1033
+ // Trigger ASK_GUARDIAN to start a consultation
980
1034
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
981
1035
  ['Checking. [ASK_GUARDIAN: Confirm cancellation?]'],
982
1036
  ));
983
1037
  const { session, relay, controller } = setupController();
984
1038
  await controller.handleCallerUtterance('I want to cancel');
985
- expect(controller.getState()).toBe('waiting_on_user');
986
-
987
- // Queue a caller utterance while waiting
988
- await controller.handleCallerUtterance('Never mind, just cancel it');
1039
+ expect(controller.getState()).toBe('idle');
1040
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
989
1041
 
990
1042
  // Set up mock so the answer turn ends the call with [END_CALL]
991
1043
  const turnContents: string[] = [];
@@ -1004,8 +1056,6 @@ describe('call-controller', () => {
1004
1056
 
1005
1057
  // The answer turn should have fired
1006
1058
  expect(turnContents.some((c) => c.includes('[USER_ANSWERED: Yes, cancel it]'))).toBe(true);
1007
- // The queued caller utterance should NOT have been processed — only the answer turn
1008
- expect(turnContents.length).toBe(1);
1009
1059
 
1010
1060
  // Call should be completed
1011
1061
  const updatedSession = getCallSession(session.id);
@@ -1021,13 +1071,14 @@ describe('call-controller', () => {
1021
1071
  // Use a short consultation timeout so we can wait for it in the test
1022
1072
  mockConsultationTimeoutMs = 50;
1023
1073
 
1024
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
1074
+ // Trigger ASK_GUARDIAN to start a consultation
1025
1075
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1026
1076
  ['Let me check. [ASK_GUARDIAN: What time works?]'],
1027
1077
  ));
1028
1078
  const { session, relay, controller } = setupController();
1029
1079
  await controller.handleCallerUtterance('Book me in');
1030
- expect(controller.getState()).toBe('waiting_on_user');
1080
+ expect(controller.getState()).toBe('idle');
1081
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1031
1082
 
1032
1083
  // Set up mock to capture what content the timeout turn receives
1033
1084
  const turnContents: string[] = [];
@@ -1041,11 +1092,12 @@ describe('call-controller', () => {
1041
1092
  // Wait for the short consultation timeout to fire
1042
1093
  await new Promise((r) => setTimeout(r, 200));
1043
1094
 
1044
- // A generated turn should have been fired with the GUARDIAN_TIMEOUT instruction
1095
+ // A generated turn should have been fired with the GUARDIAN_TIMEOUT instruction.
1096
+ // The instruction starts with '[' so flushPendingInstructions passes it through
1097
+ // without wrapping it in [USER_INSTRUCTION:].
1045
1098
  expect(turnContents.length).toBe(1);
1046
1099
  expect(turnContents[0]).toContain('[GUARDIAN_TIMEOUT]');
1047
1100
  expect(turnContents[0]).toContain('What time works?');
1048
- expect(turnContents[0]).toContain('[USER_INSTRUCTION:');
1049
1101
 
1050
1102
  // No hardcoded timeout text should appear in relay tokens
1051
1103
  const allText = relay.sentTokens.map((t) => t.token).join('');
@@ -1059,27 +1111,24 @@ describe('call-controller', () => {
1059
1111
  controller.destroy();
1060
1112
  });
1061
1113
 
1062
- test('consultation timeout: merges pending instructions and caller utterances into the timeout turn', async () => {
1114
+ test('consultation timeout: timeout instruction fires even when controller is idle', async () => {
1063
1115
  // Use a short consultation timeout so we can wait for it in the test
1064
1116
  mockConsultationTimeoutMs = 50;
1065
1117
 
1066
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
1118
+ // Trigger ASK_GUARDIAN to start a consultation
1067
1119
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1068
1120
  ['Let me check. [ASK_GUARDIAN: What time works?]'],
1069
1121
  ));
1070
1122
  const { controller } = setupController();
1071
1123
  await controller.handleCallerUtterance('Book me in');
1072
- expect(controller.getState()).toBe('waiting_on_user');
1073
-
1074
- // Queue an instruction and a caller utterance while waiting
1075
- await controller.handleUserInstruction('Suggest morning slots');
1076
- await controller.handleCallerUtterance('Actually, I prefer 10am');
1124
+ expect(controller.getState()).toBe('idle');
1125
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1077
1126
 
1078
- // Set up mock to capture what content the merged turn receives
1127
+ // Set up mock to capture what content the timeout turn receives
1079
1128
  const turnContents: string[] = [];
1080
1129
  mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
1081
1130
  turnContents.push(opts.content);
1082
- opts.onTextDelta('Got it, let me check 10am availability.');
1131
+ opts.onTextDelta('Got it, I was unable to reach them.');
1083
1132
  opts.onComplete();
1084
1133
  return { turnId: `run-${turnContents.length}`, abort: () => {} };
1085
1134
  });
@@ -1087,12 +1136,13 @@ describe('call-controller', () => {
1087
1136
  // Wait for the short consultation timeout to fire
1088
1137
  await new Promise((r) => setTimeout(r, 200));
1089
1138
 
1090
- // A single merged turn should have been fired containing instructions,
1091
- // the timeout instruction, and the caller utterance
1092
- expect(turnContents.length).toBe(1);
1093
- expect(turnContents[0]).toContain('[USER_INSTRUCTION: Suggest morning slots]');
1094
- expect(turnContents[0]).toContain('[GUARDIAN_TIMEOUT]');
1095
- expect(turnContents[0]).toContain('Actually, I prefer 10am');
1139
+ // The timeout instruction turn should have fired
1140
+ const timeoutTurns = turnContents.filter((c) => c.includes('[GUARDIAN_TIMEOUT]'));
1141
+ expect(timeoutTurns.length).toBe(1);
1142
+ expect(timeoutTurns[0]).toContain('What time works?');
1143
+
1144
+ // Consultation should be cleared after timeout
1145
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
1096
1146
 
1097
1147
  controller.destroy();
1098
1148
  });
@@ -1100,13 +1150,14 @@ describe('call-controller', () => {
1100
1150
  test('consultation timeout: marks linked guardian action request as timed out', async () => {
1101
1151
  mockConsultationTimeoutMs = 50;
1102
1152
 
1103
- // Trigger ASK_GUARDIAN to enter waiting_on_user state
1153
+ // Trigger ASK_GUARDIAN to start a consultation
1104
1154
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1105
1155
  ['Let me check. [ASK_GUARDIAN: What time works?]'],
1106
1156
  ));
1107
1157
  const { session, controller } = setupController();
1108
1158
  await controller.handleCallerUtterance('Book me in');
1109
- expect(controller.getState()).toBe('waiting_on_user');
1159
+ expect(controller.getState()).toBe('idle');
1160
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1110
1161
 
1111
1162
  // Give the async dispatchGuardianQuestion a tick to create the request
1112
1163
  await new Promise((r) => setTimeout(r, 10));
@@ -1143,13 +1194,14 @@ describe('call-controller', () => {
1143
1194
  test('ASK_GUARDIAN after timeout: skips wait and injects GUARDIAN_UNAVAILABLE instruction', async () => {
1144
1195
  mockConsultationTimeoutMs = 50;
1145
1196
 
1146
- // Step 1: Trigger ASK_GUARDIAN to enter waiting_on_user state
1197
+ // Step 1: Trigger ASK_GUARDIAN to start a consultation
1147
1198
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1148
1199
  ['Let me check. [ASK_GUARDIAN: What time works?]'],
1149
1200
  ));
1150
1201
  const { session, controller } = setupController();
1151
1202
  await controller.handleCallerUtterance('Book me in');
1152
- expect(controller.getState()).toBe('waiting_on_user');
1203
+ expect(controller.getState()).toBe('idle');
1204
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1153
1205
 
1154
1206
  // Step 2: Set up mock for timeout-generated turn
1155
1207
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
@@ -1185,8 +1237,9 @@ describe('call-controller', () => {
1185
1237
  // The second turn should contain the GUARDIAN_UNAVAILABLE instruction
1186
1238
  expect(turnCount).toBeGreaterThanOrEqual(2);
1187
1239
  expect(turnContents.some((c) => c.includes('[GUARDIAN_UNAVAILABLE]'))).toBe(true);
1188
- // Should NOT have entered waiting_on_user state
1240
+ // Controller remains idle; no new consultation created
1189
1241
  expect(controller.getState()).toBe('idle');
1242
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
1190
1243
 
1191
1244
  // The skip should be recorded as an event
1192
1245
  const events = getCallEvents(session.id);
@@ -1214,8 +1267,9 @@ describe('call-controller', () => {
1214
1267
  // Give the async dispatchGuardianQuestion a tick to create the request
1215
1268
  await new Promise((r) => setTimeout(r, 50));
1216
1269
 
1217
- // Verify controller entered waiting_on_user
1218
- expect(controller.getState()).toBe('waiting_on_user');
1270
+ // Controller returns to idle (non-blocking); consultation tracked separately
1271
+ expect(controller.getState()).toBe('idle');
1272
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1219
1273
 
1220
1274
  // Verify a pending question was created with the correct text
1221
1275
  const question = getPendingQuestion(session.id);
@@ -1322,8 +1376,9 @@ describe('call-controller', () => {
1322
1376
  await controller.handleCallerUtterance('Send it');
1323
1377
  await new Promise((r) => setTimeout(r, 50));
1324
1378
 
1325
- // Verify controller entered waiting_on_user with the correct question
1326
- expect(controller.getState()).toBe('waiting_on_user');
1379
+ // Controller returns to idle (non-blocking); consultation tracked separately
1380
+ expect(controller.getState()).toBe('idle');
1381
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1327
1382
  const question = getPendingQuestion(session.id);
1328
1383
  expect(question).not.toBeNull();
1329
1384
  expect(question!.questionText).toBe('Allow send_message?');
@@ -1365,4 +1420,280 @@ describe('call-controller', () => {
1365
1420
 
1366
1421
  controller.destroy();
1367
1422
  });
1423
+
1424
+ // ── Non-blocking race safety ───────────────────────────────────────
1425
+
1426
+ test('guardian answer during processing/speaking: queued in pendingInstructions and applied at next turn boundary', async () => {
1427
+ // Trigger ASK_GUARDIAN to start a consultation
1428
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1429
+ ['Checking. [ASK_GUARDIAN: Confirm appointment?]'],
1430
+ ));
1431
+ const { controller } = setupController();
1432
+ await controller.handleCallerUtterance('I want to schedule');
1433
+ expect(controller.getState()).toBe('idle');
1434
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1435
+
1436
+ // Start a new turn (caller follow-up) to put controller in processing state
1437
+ let firstTurnResolve: (() => void) | null = null;
1438
+ const turnContents: string[] = [];
1439
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
1440
+ turnContents.push(opts.content);
1441
+ if (!firstTurnResolve) {
1442
+ // First turn: pause to simulate processing state
1443
+ await new Promise<void>((resolve) => { firstTurnResolve = resolve; });
1444
+ }
1445
+ opts.onTextDelta('Response.');
1446
+ opts.onComplete();
1447
+ return { turnId: `run-${turnContents.length}`, abort: () => {} };
1448
+ });
1449
+
1450
+ // Start a caller turn that will pause mid-processing
1451
+ const callerTurnPromise = controller.handleCallerUtterance('Are you still there?');
1452
+ await new Promise((r) => setTimeout(r, 10));
1453
+ expect(controller.getState()).toBe('speaking');
1454
+
1455
+ // Answer arrives while the controller is processing/speaking
1456
+ const accepted = await controller.handleUserAnswer('3pm works');
1457
+ expect(accepted).toBe(true);
1458
+ // Consultation is consumed immediately
1459
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
1460
+
1461
+ // Complete the first turn so the answer instruction flushes
1462
+ firstTurnResolve!();
1463
+ await callerTurnPromise;
1464
+
1465
+ // Give the flushed instruction turn time to complete
1466
+ await new Promise((r) => setTimeout(r, 100));
1467
+
1468
+ // The queued USER_ANSWERED instruction should have been applied
1469
+ expect(turnContents.some((c) => c.includes('[USER_ANSWERED: 3pm works]'))).toBe(true);
1470
+
1471
+ controller.destroy();
1472
+ });
1473
+
1474
+ test('timeout + late answer: after timeout, a late answer is rejected as stale', async () => {
1475
+ mockConsultationTimeoutMs = 50;
1476
+
1477
+ // Trigger ASK_GUARDIAN to start a consultation
1478
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1479
+ ['Let me check. [ASK_GUARDIAN: What time works?]'],
1480
+ ));
1481
+ const { controller } = setupController();
1482
+ await controller.handleCallerUtterance('Book me in');
1483
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1484
+
1485
+ // Set up mock for the timeout-generated turn
1486
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1487
+ ['Sorry, I could not reach them.'],
1488
+ ));
1489
+
1490
+ // Wait for the consultation timeout to expire the consultation
1491
+ await new Promise((r) => setTimeout(r, 200));
1492
+
1493
+ // Consultation should be cleared by the timeout
1494
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
1495
+
1496
+ // A late answer should be rejected
1497
+ const lateResult = await controller.handleUserAnswer('3pm is fine');
1498
+ expect(lateResult).toBe(false);
1499
+
1500
+ controller.destroy();
1501
+ });
1502
+
1503
+ test('caller follow-up processed normally while consultation pending', async () => {
1504
+ // Trigger ASK_GUARDIAN to start a consultation
1505
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1506
+ ['Let me check. [ASK_GUARDIAN: What date?]'],
1507
+ ));
1508
+ const { relay, controller } = setupController();
1509
+ await controller.handleCallerUtterance('Schedule something');
1510
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1511
+
1512
+ // Caller follows up while consultation is pending
1513
+ const turnContents: string[] = [];
1514
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
1515
+ turnContents.push(opts.content);
1516
+ opts.onTextDelta('Of course, what else can I help with?');
1517
+ opts.onComplete();
1518
+ return { turnId: `run-${turnContents.length}`, abort: () => {} };
1519
+ });
1520
+
1521
+ await controller.handleCallerUtterance('Can you also check availability?');
1522
+
1523
+ // The follow-up should trigger a normal turn (non-blocking)
1524
+ expect(turnContents.length).toBe(1);
1525
+ expect(turnContents[0]).toContain('Can you also check availability?');
1526
+
1527
+ // Consultation should still be pending
1528
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
1529
+
1530
+ // Response should appear in relay
1531
+ const allText = relay.sentTokens.map((t) => t.token).join('');
1532
+ expect(allText).toContain('what else can I help with');
1533
+
1534
+ controller.destroy();
1535
+ });
1536
+
1537
+ // ── Consultation coalescing (Incident C) ────────────────────────────
1538
+
1539
+ test('coalescing: repeated identical informational ASK_GUARDIAN does not create a new request', async () => {
1540
+ // Trigger first ASK_GUARDIAN
1541
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1542
+ ['Let me ask. [ASK_GUARDIAN: Preferred date?]'],
1543
+ ));
1544
+ const { session, controller } = setupController();
1545
+ await controller.handleCallerUtterance('Schedule please');
1546
+ await new Promise((r) => setTimeout(r, 50));
1547
+
1548
+ const firstQuestionId = controller.getPendingConsultationQuestionId();
1549
+ expect(firstQuestionId).not.toBeNull();
1550
+ const firstRequest = getPendingRequestByCallSessionId(session.id);
1551
+ expect(firstRequest).not.toBeNull();
1552
+
1553
+ // Repeated ASK_GUARDIAN with same informational question (no tool metadata)
1554
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1555
+ ['Still checking. [ASK_GUARDIAN: Preferred date?]'],
1556
+ ));
1557
+ await controller.handleCallerUtterance('Hello? Still there?');
1558
+ await new Promise((r) => setTimeout(r, 50));
1559
+
1560
+ // Should coalesce: same consultation ID, same request
1561
+ expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
1562
+ const currentRequest = getPendingRequestByCallSessionId(session.id);
1563
+ expect(currentRequest).not.toBeNull();
1564
+ expect(currentRequest!.id).toBe(firstRequest!.id);
1565
+ expect(currentRequest!.status).toBe('pending');
1566
+
1567
+ // Coalesce event should be recorded
1568
+ const events = getCallEvents(session.id);
1569
+ const coalesceEvents = events.filter((e) => e.eventType === 'guardian_consult_coalesced');
1570
+ expect(coalesceEvents.length).toBe(1);
1571
+
1572
+ controller.destroy();
1573
+ });
1574
+
1575
+ test('coalescing: repeated ASK_GUARDIAN_APPROVAL with same tool/input does not create a new request', async () => {
1576
+ const approvalPayload = JSON.stringify({
1577
+ question: 'Allow send_email to bob@example.com?',
1578
+ toolName: 'send_email',
1579
+ input: { to: 'bob@example.com', subject: 'Hello' },
1580
+ });
1581
+
1582
+ // First ASK_GUARDIAN_APPROVAL
1583
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1584
+ [`Checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1585
+ ));
1586
+ const { session, controller } = setupController('Send email');
1587
+ await controller.handleCallerUtterance('Send email to Bob');
1588
+ await new Promise((r) => setTimeout(r, 50));
1589
+
1590
+ const firstQuestionId = controller.getPendingConsultationQuestionId();
1591
+ expect(firstQuestionId).not.toBeNull();
1592
+ const firstRequest = getPendingRequestByCallSessionId(session.id);
1593
+ expect(firstRequest).not.toBeNull();
1594
+
1595
+ // Repeated ASK_GUARDIAN_APPROVAL with same tool/input
1596
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1597
+ [`Still checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1598
+ ));
1599
+ await controller.handleCallerUtterance('Can you send it already?');
1600
+ await new Promise((r) => setTimeout(r, 50));
1601
+
1602
+ // Should coalesce: same consultation, same request
1603
+ expect(controller.getPendingConsultationQuestionId()).toBe(firstQuestionId);
1604
+ const currentRequest = getPendingRequestByCallSessionId(session.id);
1605
+ expect(currentRequest!.id).toBe(firstRequest!.id);
1606
+ expect(currentRequest!.status).toBe('pending');
1607
+
1608
+ controller.destroy();
1609
+ });
1610
+
1611
+ test('supersession: materially different tool triggers new request with superseded metadata', async () => {
1612
+ const firstPayload = JSON.stringify({
1613
+ question: 'Allow send_email?',
1614
+ toolName: 'send_email',
1615
+ input: { to: 'bob@example.com' },
1616
+ });
1617
+
1618
+ // First ASK_GUARDIAN_APPROVAL for send_email
1619
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1620
+ [`Checking. [ASK_GUARDIAN_APPROVAL: ${firstPayload}]`],
1621
+ ));
1622
+ const { session, controller } = setupController('Process request');
1623
+ await controller.handleCallerUtterance('Send email');
1624
+ await new Promise((r) => setTimeout(r, 50));
1625
+
1626
+ const firstRequest = getPendingRequestByCallSessionId(session.id);
1627
+ expect(firstRequest).not.toBeNull();
1628
+ expect(firstRequest!.toolName).toBe('send_email');
1629
+
1630
+ // Different tool — should supersede
1631
+ const secondPayload = JSON.stringify({
1632
+ question: 'Allow calendar_create?',
1633
+ toolName: 'calendar_create',
1634
+ input: { date: '2026-03-01' },
1635
+ });
1636
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1637
+ [`Actually, let me do this. [ASK_GUARDIAN_APPROVAL: ${secondPayload}]`],
1638
+ ));
1639
+ await controller.handleCallerUtterance('Actually, create a calendar event instead');
1640
+ await new Promise((r) => setTimeout(r, 100));
1641
+
1642
+ // New consultation should be active
1643
+ const secondRequest = getPendingRequestByCallSessionId(session.id);
1644
+ expect(secondRequest).not.toBeNull();
1645
+ expect(secondRequest!.id).not.toBe(firstRequest!.id);
1646
+ expect(secondRequest!.toolName).toBe('calendar_create');
1647
+
1648
+ // Old request should be expired with 'superseded' reason
1649
+ const expiredRequest = getGuardianActionRequest(firstRequest!.id);
1650
+ expect(expiredRequest).not.toBeNull();
1651
+ expect(expiredRequest!.status).toBe('expired');
1652
+ expect(expiredRequest!.expiredReason).toBe('superseded');
1653
+ expect(expiredRequest!.supersededByRequestId).toBe(secondRequest!.id);
1654
+ expect(expiredRequest!.supersededAt).not.toBeNull();
1655
+
1656
+ controller.destroy();
1657
+ });
1658
+
1659
+ test('tool metadata continuity: re-ask without structured metadata inherits tool scope from prior consultation', async () => {
1660
+ const approvalPayload = JSON.stringify({
1661
+ question: 'Allow send_email?',
1662
+ toolName: 'send_email',
1663
+ input: { to: 'bob@example.com', subject: 'Hello' },
1664
+ });
1665
+
1666
+ // First ask with structured tool metadata
1667
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1668
+ [`Let me check. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1669
+ ));
1670
+ const { session, controller } = setupController('Send email');
1671
+ await controller.handleCallerUtterance('Send email to Bob');
1672
+ await new Promise((r) => setTimeout(r, 50));
1673
+
1674
+ const firstRequest = getPendingRequestByCallSessionId(session.id);
1675
+ expect(firstRequest).not.toBeNull();
1676
+ expect(firstRequest!.toolName).toBe('send_email');
1677
+
1678
+ // Re-ask with informational ASK_GUARDIAN (no structured metadata).
1679
+ // Since the tool metadata matches the existing consultation (inherited),
1680
+ // this should coalesce rather than supersede.
1681
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1682
+ ['Checking again. [ASK_GUARDIAN: Can I send that email?]'],
1683
+ ));
1684
+ await controller.handleCallerUtterance('Can you hurry up?');
1685
+ await new Promise((r) => setTimeout(r, 50));
1686
+
1687
+ // Should coalesce: the inherited tool metadata matches the existing consultation
1688
+ const currentRequest = getPendingRequestByCallSessionId(session.id);
1689
+ expect(currentRequest!.id).toBe(firstRequest!.id);
1690
+ expect(currentRequest!.status).toBe('pending');
1691
+
1692
+ // Coalesce event should be recorded
1693
+ const events = getCallEvents(session.id);
1694
+ const coalesceEvents = events.filter((e) => e.eventType === 'guardian_consult_coalesced');
1695
+ expect(coalesceEvents.length).toBe(1);
1696
+
1697
+ controller.destroy();
1698
+ });
1368
1699
  });