@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.
- package/ARCHITECTURE.md +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- 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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
478
|
-
expect(controller.getState()).toBe('
|
|
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
|
-
//
|
|
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
|
|
542
|
+
test('handleUserAnswer: returns false when no pending consultation exists', async () => {
|
|
534
543
|
const { controller } = setupController();
|
|
535
544
|
|
|
536
|
-
//
|
|
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
|
-
// ──
|
|
830
|
+
// ── Non-blocking consultation: caller follow-up during pending consultation ──
|
|
822
831
|
|
|
823
|
-
test('handleCallerUtterance:
|
|
824
|
-
// Trigger ASK_GUARDIAN to
|
|
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
|
-
|
|
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('
|
|
847
|
+
opts.onTextDelta('Sure, let me help with that.');
|
|
837
848
|
opts.onComplete();
|
|
838
|
-
return { turnId: 'run-
|
|
849
|
+
return { turnId: 'run-followup', abort: () => {} };
|
|
839
850
|
});
|
|
840
851
|
|
|
841
|
-
// Caller speaks while
|
|
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(
|
|
844
|
-
|
|
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('
|
|
850
|
-
// Trigger ASK_GUARDIAN to
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
883
|
-
expect(
|
|
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:
|
|
889
|
-
// Trigger ASK_GUARDIAN to
|
|
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('
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
expect(
|
|
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
|
|
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 —
|
|
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('
|
|
944
|
-
//
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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:
|
|
999
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
960
1000
|
});
|
|
961
1001
|
|
|
962
|
-
|
|
963
|
-
|
|
1002
|
+
const { session, controller } = setupController();
|
|
1003
|
+
const turnPromise = controller.handleCallerUtterance('Hello');
|
|
1004
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
964
1005
|
|
|
965
|
-
//
|
|
966
|
-
|
|
1006
|
+
// Inject instruction while processing — should be queued
|
|
1007
|
+
await controller.handleUserInstruction('Suggest morning slots');
|
|
967
1008
|
|
|
968
|
-
//
|
|
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:
|
|
979
|
-
// Trigger ASK_GUARDIAN to
|
|
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('
|
|
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
|
|
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('
|
|
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:
|
|
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
|
|
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('
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
1091
|
-
|
|
1092
|
-
expect(
|
|
1093
|
-
expect(
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1218
|
-
expect(controller.getState()).toBe('
|
|
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
|
-
//
|
|
1326
|
-
expect(controller.getState()).toBe('
|
|
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
|
});
|