@vellumai/assistant 0.4.31 → 0.4.32

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 (121) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
  4. package/src/__tests__/anthropic-provider.test.ts +86 -1
  5. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  6. package/src/__tests__/checker.test.ts +37 -98
  7. package/src/__tests__/commit-message-enrichment-service.test.ts +15 -0
  8. package/src/__tests__/config-schema.test.ts +6 -5
  9. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  10. package/src/__tests__/daemon-server-session-init.test.ts +1 -19
  11. package/src/__tests__/followup-tools.test.ts +0 -30
  12. package/src/__tests__/gemini-provider.test.ts +79 -1
  13. package/src/__tests__/ipc-snapshot.test.ts +0 -4
  14. package/src/__tests__/managed-proxy-context.test.ts +163 -0
  15. package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
  16. package/src/__tests__/memory-regressions.test.ts +6 -6
  17. package/src/__tests__/openai-provider.test.ts +82 -0
  18. package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
  19. package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
  20. package/src/__tests__/recurrence-types.test.ts +0 -15
  21. package/src/__tests__/schedule-tools.test.ts +28 -44
  22. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  23. package/src/__tests__/task-management-tools.test.ts +111 -0
  24. package/src/__tests__/twilio-config.test.ts +0 -3
  25. package/src/amazon/session.ts +30 -91
  26. package/src/calls/call-controller.ts +423 -571
  27. package/src/calls/finalize-call.ts +20 -0
  28. package/src/calls/relay-access-wait.ts +340 -0
  29. package/src/calls/relay-server.ts +267 -902
  30. package/src/calls/relay-setup-router.ts +307 -0
  31. package/src/calls/relay-verification.ts +280 -0
  32. package/src/calls/twilio-config.ts +1 -8
  33. package/src/calls/voice-control-protocol.ts +184 -0
  34. package/src/calls/voice-session-bridge.ts +1 -8
  35. package/src/config/agent-schema.ts +1 -1
  36. package/src/config/bundled-skills/followups/TOOLS.json +0 -4
  37. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  38. package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
  39. package/src/config/core-schema.ts +1 -1
  40. package/src/config/env.ts +0 -10
  41. package/src/config/feature-flag-registry.json +1 -1
  42. package/src/config/loader.ts +19 -0
  43. package/src/config/schema.ts +2 -2
  44. package/src/daemon/handlers/session-history.ts +398 -0
  45. package/src/daemon/handlers/session-user-message.ts +982 -0
  46. package/src/daemon/handlers/sessions.ts +9 -1338
  47. package/src/daemon/ipc-contract/sessions.ts +0 -6
  48. package/src/daemon/ipc-contract-inventory.json +0 -1
  49. package/src/daemon/lifecycle.ts +0 -29
  50. package/src/home-base/app-link-store.ts +0 -7
  51. package/src/memory/conversation-attention-store.ts +1 -1
  52. package/src/memory/conversation-store.ts +0 -51
  53. package/src/memory/db-init.ts +5 -1
  54. package/src/memory/job-handlers/conflict.ts +24 -0
  55. package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
  56. package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
  57. package/src/memory/migrations/registry.ts +6 -0
  58. package/src/memory/recall-cache.ts +0 -5
  59. package/src/memory/schema/calls.ts +274 -0
  60. package/src/memory/schema/contacts.ts +125 -0
  61. package/src/memory/schema/conversations.ts +129 -0
  62. package/src/memory/schema/guardian.ts +172 -0
  63. package/src/memory/schema/index.ts +8 -0
  64. package/src/memory/schema/infrastructure.ts +205 -0
  65. package/src/memory/schema/memory-core.ts +196 -0
  66. package/src/memory/schema/notifications.ts +191 -0
  67. package/src/memory/schema/tasks.ts +78 -0
  68. package/src/memory/schema.ts +1 -1385
  69. package/src/memory/slack-thread-store.ts +0 -69
  70. package/src/notifications/decisions-store.ts +2 -105
  71. package/src/notifications/deliveries-store.ts +0 -11
  72. package/src/notifications/preferences-store.ts +1 -58
  73. package/src/permissions/checker.ts +6 -17
  74. package/src/providers/anthropic/client.ts +6 -2
  75. package/src/providers/gemini/client.ts +13 -2
  76. package/src/providers/managed-proxy/constants.ts +55 -0
  77. package/src/providers/managed-proxy/context.ts +77 -0
  78. package/src/providers/registry.ts +112 -0
  79. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
  80. package/src/runtime/http-server.ts +83 -722
  81. package/src/runtime/http-types.ts +0 -16
  82. package/src/runtime/middleware/auth.ts +0 -12
  83. package/src/runtime/routes/app-routes.ts +33 -0
  84. package/src/runtime/routes/approval-routes.ts +32 -0
  85. package/src/runtime/routes/attachment-routes.ts +32 -0
  86. package/src/runtime/routes/brain-graph-routes.ts +27 -0
  87. package/src/runtime/routes/call-routes.ts +41 -0
  88. package/src/runtime/routes/channel-readiness-routes.ts +20 -0
  89. package/src/runtime/routes/channel-routes.ts +70 -0
  90. package/src/runtime/routes/contact-routes.ts +63 -0
  91. package/src/runtime/routes/conversation-attention-routes.ts +15 -0
  92. package/src/runtime/routes/conversation-routes.ts +190 -193
  93. package/src/runtime/routes/debug-routes.ts +15 -0
  94. package/src/runtime/routes/events-routes.ts +16 -0
  95. package/src/runtime/routes/global-search-routes.ts +15 -0
  96. package/src/runtime/routes/guardian-action-routes.ts +22 -0
  97. package/src/runtime/routes/guardian-bootstrap-routes.ts +20 -0
  98. package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
  99. package/src/runtime/routes/identity-routes.ts +20 -0
  100. package/src/runtime/routes/inbound-message-handler.ts +8 -0
  101. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +6 -6
  102. package/src/runtime/routes/integration-routes.ts +83 -0
  103. package/src/runtime/routes/invite-routes.ts +31 -0
  104. package/src/runtime/routes/migration-routes.ts +30 -0
  105. package/src/runtime/routes/pairing-routes.ts +18 -0
  106. package/src/runtime/routes/secret-routes.ts +20 -0
  107. package/src/runtime/routes/surface-action-routes.ts +26 -0
  108. package/src/runtime/routes/trust-rules-routes.ts +31 -0
  109. package/src/runtime/routes/twilio-routes.ts +79 -0
  110. package/src/schedule/recurrence-types.ts +1 -11
  111. package/src/tools/followups/followup_create.ts +9 -3
  112. package/src/tools/mcp/mcp-tool-factory.ts +0 -17
  113. package/src/tools/memory/definitions.ts +0 -6
  114. package/src/tools/network/script-proxy/session-manager.ts +38 -3
  115. package/src/tools/schedule/create.ts +1 -3
  116. package/src/tools/schedule/update.ts +9 -6
  117. package/src/twitter/session.ts +29 -77
  118. package/src/util/cookie-session.ts +114 -0
  119. package/src/__tests__/conversation-routes.test.ts +0 -99
  120. package/src/__tests__/task-tools.test.ts +0 -685
  121. package/src/contacts/startup-migration.ts +0 -21
@@ -27,10 +27,8 @@ import {
27
27
  getSilenceTimeoutMs,
28
28
  getUserConsultationTimeoutMs,
29
29
  } from "./call-constants.js";
30
- import { persistCallCompletionMessage } from "./call-conversation-messages.js";
31
30
  import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
32
31
  import {
33
- fireCallCompletionNotifier,
34
32
  fireCallQuestionNotifier,
35
33
  fireCallTranscriptNotifier,
36
34
  registerCallController,
@@ -43,10 +41,20 @@ import {
43
41
  recordCallEvent,
44
42
  updateCallSession,
45
43
  } from "./call-store.js";
44
+ import { finalizeCall } from "./finalize-call.js";
46
45
  import { sendGuardianExpiryNotices } from "./guardian-action-sweep.js";
47
46
  import { dispatchGuardianQuestion } from "./guardian-dispatch.js";
48
47
  import type { RelayConnection } from "./relay-server.js";
49
48
  import type { PromptSpeakerContext } from "./speaker-identification.js";
49
+ import {
50
+ ASK_GUARDIAN_CAPTURE_REGEX,
51
+ CALL_OPENING_ACK_MARKER,
52
+ CALL_OPENING_MARKER,
53
+ couldBeControlMarker,
54
+ END_CALL_MARKER,
55
+ extractBalancedJson,
56
+ stripInternalSpeechMarkers,
57
+ } from "./voice-control-protocol.js";
50
58
  import {
51
59
  startVoiceTurn,
52
60
  type VoiceTurnHandle,
@@ -70,133 +78,6 @@ interface PendingGuardianInput {
70
78
  timer: ReturnType<typeof setTimeout>;
71
79
  }
72
80
 
73
- const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
74
- const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
75
-
76
- // Flexible prefix for ASK_GUARDIAN_APPROVAL — tolerates variable whitespace
77
- // after the colon so the marker is recognized even if the model omits the
78
- // space or inserts a newline.
79
- const ASK_GUARDIAN_APPROVAL_PREFIX_RE = /\[ASK_GUARDIAN_APPROVAL:\s*/;
80
-
81
- /**
82
- * Extract a balanced JSON object from text that starts with an
83
- * ASK_GUARDIAN_APPROVAL prefix. Uses brace counting with string-literal
84
- * awareness so that `}` or `}]` inside JSON string values does not
85
- * terminate the match prematurely.
86
- *
87
- * Returns the extracted JSON string, the full marker text
88
- * (prefix + JSON + "]"), and the start index — or null when:
89
- * - no prefix is found,
90
- * - braces are unbalanced (still streaming), or
91
- * - the closing `]` has not yet arrived (prevents stripping
92
- * the marker body while the bracket leaks into TTS in a later delta).
93
- */
94
- function extractBalancedJson(
95
- text: string,
96
- ): { json: string; fullMatch: string; startIndex: number } | null {
97
- const prefixMatch = ASK_GUARDIAN_APPROVAL_PREFIX_RE.exec(text);
98
- if (!prefixMatch) return null;
99
-
100
- const prefixIdx = prefixMatch.index;
101
- const jsonStart = prefixIdx + prefixMatch[0].length;
102
- if (jsonStart >= text.length || text[jsonStart] !== "{") return null;
103
-
104
- let depth = 0;
105
- let inString = false;
106
- let escape = false;
107
-
108
- for (let i = jsonStart; i < text.length; i++) {
109
- const ch = text[i];
110
-
111
- if (escape) {
112
- escape = false;
113
- continue;
114
- }
115
-
116
- if (ch === "\\" && inString) {
117
- escape = true;
118
- continue;
119
- }
120
-
121
- if (ch === '"') {
122
- inString = !inString;
123
- continue;
124
- }
125
-
126
- if (inString) continue;
127
-
128
- if (ch === "{") {
129
- depth++;
130
- } else if (ch === "}") {
131
- depth--;
132
- if (depth === 0) {
133
- const jsonEnd = i + 1;
134
- const json = text.slice(jsonStart, jsonEnd);
135
- // Skip any whitespace between the closing '}' and the expected ']'.
136
- // Models sometimes emit formatted markers with spaces or newlines
137
- // before the bracket (e.g. `{ ... }\n]` or `{ ... } ]`).
138
- let bracketIdx = jsonEnd;
139
- while (bracketIdx < text.length && /\s/.test(text[bracketIdx])) {
140
- bracketIdx++;
141
- }
142
- // Require the closing ']' to be present before considering this
143
- // a complete match. If it hasn't arrived yet (streaming), return
144
- // null so the caller keeps buffering.
145
- if (bracketIdx >= text.length || text[bracketIdx] !== "]") {
146
- return null;
147
- }
148
- const fullMatchEnd = bracketIdx + 1;
149
- const fullMatch = text.slice(prefixIdx, fullMatchEnd);
150
- return { json, fullMatch, startIndex: prefixIdx };
151
- }
152
- }
153
- }
154
-
155
- return null; // Unbalanced braces — still streaming
156
- }
157
-
158
- /**
159
- * Strip all balanced ASK_GUARDIAN_APPROVAL markers from text, handling
160
- * nested braces, string literals, and flexible whitespace correctly.
161
- * Only strips complete markers (prefix + balanced JSON + closing `]`).
162
- */
163
- function stripGuardianApprovalMarkers(text: string): string {
164
- let result = text;
165
- for (;;) {
166
- const match = extractBalancedJson(result);
167
- if (!match) break;
168
- result =
169
- result.slice(0, match.startIndex) +
170
- result.slice(match.startIndex + match.fullMatch.length);
171
- }
172
- return result;
173
- }
174
-
175
- const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
176
- const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
177
- const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
178
- const CALL_OPENING_ACK_MARKER_REGEX = /\[CALL_OPENING_ACK\]/g;
179
- const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
180
- const GUARDIAN_TIMEOUT_MARKER_REGEX = /\[GUARDIAN_TIMEOUT\]/g;
181
- const GUARDIAN_UNAVAILABLE_MARKER_REGEX = /\[GUARDIAN_UNAVAILABLE\]/g;
182
- const CALL_OPENING_MARKER = "[CALL_OPENING]";
183
- const CALL_OPENING_ACK_MARKER = "[CALL_OPENING_ACK]";
184
- const END_CALL_MARKER = "[END_CALL]";
185
-
186
- function stripInternalSpeechMarkers(text: string): string {
187
- let result = stripGuardianApprovalMarkers(text);
188
- result = result
189
- .replace(ASK_GUARDIAN_MARKER_REGEX, "")
190
- .replace(USER_ANSWERED_MARKER_REGEX, "")
191
- .replace(USER_INSTRUCTION_MARKER_REGEX, "")
192
- .replace(CALL_OPENING_MARKER_REGEX, "")
193
- .replace(CALL_OPENING_ACK_MARKER_REGEX, "")
194
- .replace(END_CALL_MARKER_REGEX, "")
195
- .replace(GUARDIAN_TIMEOUT_MARKER_REGEX, "")
196
- .replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, "");
197
- return result;
198
- }
199
-
200
81
  export class CallController {
201
82
  private callSessionId: string;
202
83
  private relay: RelayConnection;
@@ -562,476 +443,463 @@ export class CallController {
562
443
  try {
563
444
  this.state = "speaking";
564
445
 
565
- // Buffer incoming tokens so we can strip control markers ([ASK_GUARDIAN:...], [END_CALL])
566
- // before they reach TTS. We hold text whenever an unmatched '[' appears, since it
567
- // could be the start of a control marker.
568
- let ttsBuffer = "";
569
- // Accumulate the full response text for post-turn marker detection
570
- let fullResponseText = "";
446
+ const fullResponseText = await this.streamTtsTokens(
447
+ content,
448
+ runVersion,
449
+ runSignal,
450
+ );
451
+ if (!this.isCurrentRun(runVersion)) return;
571
452
 
572
- const flushSafeText = (): void => {
573
- if (!this.isCurrentRun(runVersion)) return;
574
- if (ttsBuffer.length === 0) return;
575
- const bracketIdx = ttsBuffer.indexOf("[");
576
- if (bracketIdx === -1) {
577
- // No bracket at all — safe to flush everything
578
- this.relay.sendTextToken(ttsBuffer, false);
579
- ttsBuffer = "";
580
- } else {
581
- // Flush everything before the bracket
582
- if (bracketIdx > 0) {
583
- this.relay.sendTextToken(ttsBuffer.slice(0, bracketIdx), false);
584
- ttsBuffer = ttsBuffer.slice(bracketIdx);
585
- }
453
+ this.handleTurnCompletion(fullResponseText);
454
+ } catch (err: unknown) {
455
+ this.currentTurnHandle = null;
456
+ // Aborted requests are expected (interruptions, rapid utterances)
457
+ if (this.isExpectedAbortError(err) || runSignal.aborted) {
458
+ log.debug(
459
+ {
460
+ callSessionId: this.callSessionId,
461
+ errName: err instanceof Error ? err.name : typeof err,
462
+ stale: !this.isCurrentRun(runVersion),
463
+ },
464
+ "Voice turn aborted",
465
+ );
466
+ if (this.isCurrentRun(runVersion)) {
467
+ this.state = "idle";
468
+ this.resetSilenceTimer();
469
+ }
470
+ return;
471
+ }
472
+ if (!this.isCurrentRun(runVersion)) {
473
+ log.debug(
474
+ {
475
+ callSessionId: this.callSessionId,
476
+ errName: err instanceof Error ? err.name : typeof err,
477
+ },
478
+ "Ignoring stale voice turn error from superseded turn",
479
+ );
480
+ return;
481
+ }
482
+ log.error({ err, callSessionId: this.callSessionId }, "Voice turn error");
483
+ this.relay.sendTextToken(
484
+ "I'm sorry, I encountered a technical issue. Could you repeat that?",
485
+ true,
486
+ );
487
+ this.state = "idle";
488
+ this.resetSilenceTimer();
489
+ this.flushPendingInstructions();
490
+ }
491
+ }
586
492
 
587
- // Only hold the buffer if the bracket text could be the start of a
588
- // known control marker. Otherwise flush immediately so ordinary
589
- // bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
590
- const afterBracket = ttsBuffer;
591
- const couldBeControl =
592
- "[ASK_GUARDIAN_APPROVAL:".startsWith(afterBracket) ||
593
- "[ASK_GUARDIAN:".startsWith(afterBracket) ||
594
- "[USER_ANSWERED:".startsWith(afterBracket) ||
595
- "[USER_INSTRUCTION:".startsWith(afterBracket) ||
596
- "[CALL_OPENING]".startsWith(afterBracket) ||
597
- "[CALL_OPENING_ACK]".startsWith(afterBracket) ||
598
- "[END_CALL]".startsWith(afterBracket) ||
599
- "[GUARDIAN_TIMEOUT]".startsWith(afterBracket) ||
600
- "[GUARDIAN_UNAVAILABLE]".startsWith(afterBracket) ||
601
- afterBracket.startsWith("[ASK_GUARDIAN_APPROVAL:") ||
602
- afterBracket.startsWith("[ASK_GUARDIAN:") ||
603
- afterBracket.startsWith("[USER_ANSWERED:") ||
604
- afterBracket.startsWith("[USER_INSTRUCTION:") ||
605
- afterBracket === "[CALL_OPENING" ||
606
- afterBracket.startsWith("[CALL_OPENING]") ||
607
- afterBracket === "[CALL_OPENING_ACK" ||
608
- afterBracket.startsWith("[CALL_OPENING_ACK]") ||
609
- afterBracket === "[END_CALL" ||
610
- afterBracket.startsWith("[END_CALL]") ||
611
- afterBracket === "[GUARDIAN_TIMEOUT" ||
612
- afterBracket.startsWith("[GUARDIAN_TIMEOUT]") ||
613
- afterBracket === "[GUARDIAN_UNAVAILABLE" ||
614
- afterBracket.startsWith("[GUARDIAN_UNAVAILABLE]");
615
-
616
- if (!couldBeControl) {
617
- // Not a control marker prefix — flush up to the next '[' (if any)
618
- const nextBracket = ttsBuffer.indexOf("[", 1);
619
- if (nextBracket === -1) {
620
- this.relay.sendTextToken(ttsBuffer, false);
621
- ttsBuffer = "";
622
- } else {
623
- this.relay.sendTextToken(ttsBuffer.slice(0, nextBracket), false);
624
- ttsBuffer = ttsBuffer.slice(nextBracket);
625
- }
493
+ /**
494
+ * Stream TTS tokens from the session pipeline, buffering to strip
495
+ * control markers before they reach the relay. Returns the full
496
+ * accumulated response text for post-turn marker detection.
497
+ */
498
+ private async streamTtsTokens(
499
+ content: string,
500
+ runVersion: number,
501
+ runSignal: AbortSignal,
502
+ ): Promise<string> {
503
+ // Buffer incoming tokens so we can strip control markers ([ASK_GUARDIAN:...], [END_CALL])
504
+ // before they reach TTS. We hold text whenever an unmatched '[' appears, since it
505
+ // could be the start of a control marker.
506
+ let ttsBuffer = "";
507
+ let fullResponseText = "";
508
+
509
+ const flushSafeText = (): void => {
510
+ if (!this.isCurrentRun(runVersion)) return;
511
+ if (ttsBuffer.length === 0) return;
512
+ const bracketIdx = ttsBuffer.indexOf("[");
513
+ if (bracketIdx === -1) {
514
+ // No bracket at all — safe to flush everything
515
+ this.relay.sendTextToken(ttsBuffer, false);
516
+ ttsBuffer = "";
517
+ } else {
518
+ // Flush everything before the bracket
519
+ if (bracketIdx > 0) {
520
+ this.relay.sendTextToken(ttsBuffer.slice(0, bracketIdx), false);
521
+ ttsBuffer = ttsBuffer.slice(bracketIdx);
522
+ }
523
+
524
+ // Only hold the buffer if the bracket text could be the start of a
525
+ // known control marker. Otherwise flush immediately so ordinary
526
+ // bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
527
+ const afterBracket = ttsBuffer;
528
+ const couldBeControl = couldBeControlMarker(afterBracket);
529
+
530
+ if (!couldBeControl) {
531
+ // Not a control marker prefix — flush up to the next '[' (if any)
532
+ const nextBracket = ttsBuffer.indexOf("[", 1);
533
+ if (nextBracket === -1) {
534
+ this.relay.sendTextToken(ttsBuffer, false);
535
+ ttsBuffer = "";
536
+ } else {
537
+ this.relay.sendTextToken(ttsBuffer.slice(0, nextBracket), false);
538
+ ttsBuffer = ttsBuffer.slice(nextBracket);
626
539
  }
627
- // Otherwise hold it — might be a control marker still being streamed
628
540
  }
629
- };
541
+ // Otherwise hold it — might be a control marker still being streamed
542
+ }
543
+ };
630
544
 
631
- // Use a promise to track completion of the voice turn
632
- const turnComplete = new Promise<void>((resolve, reject) => {
633
- const onTextDelta = (text: string): void => {
634
- if (!this.isCurrentRun(runVersion)) return;
635
- fullResponseText += text;
636
- ttsBuffer += text;
637
- ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
638
- flushSafeText();
639
- };
545
+ // Use a promise to track completion of the voice turn
546
+ const turnComplete = new Promise<void>((resolve, reject) => {
547
+ const onTextDelta = (text: string): void => {
548
+ if (!this.isCurrentRun(runVersion)) return;
549
+ fullResponseText += text;
550
+ ttsBuffer += text;
551
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
552
+ flushSafeText();
553
+ };
640
554
 
641
- const onComplete = (): void => {
642
- resolve();
643
- };
555
+ const onComplete = (): void => {
556
+ resolve();
557
+ };
644
558
 
645
- const onError = (message: string): void => {
646
- reject(new Error(message));
647
- };
559
+ const onError = (message: string): void => {
560
+ reject(new Error(message));
561
+ };
648
562
 
649
- // Start the voice turn through the session bridge
650
- startVoiceTurn({
651
- conversationId: this.conversationId,
652
- callSessionId: this.callSessionId,
653
- content,
654
- assistantId: this.assistantId,
655
- trustContext: this.trustContext ?? undefined,
656
- isInbound: this.isInbound,
657
- task: this.task,
658
- onTextDelta,
659
- onComplete,
660
- onError,
661
- signal: runSignal,
563
+ // Start the voice turn through the session bridge
564
+ startVoiceTurn({
565
+ conversationId: this.conversationId,
566
+ callSessionId: this.callSessionId,
567
+ content,
568
+ assistantId: this.assistantId,
569
+ trustContext: this.trustContext ?? undefined,
570
+ isInbound: this.isInbound,
571
+ task: this.task,
572
+ onTextDelta,
573
+ onComplete,
574
+ onError,
575
+ signal: runSignal,
576
+ })
577
+ .then((handle) => {
578
+ if (this.isCurrentRun(runVersion)) {
579
+ this.currentTurnHandle = handle;
580
+ } else {
581
+ // Turn was superseded before handle arrived; abort immediately
582
+ handle.abort();
583
+ }
662
584
  })
663
- .then((handle) => {
664
- if (this.isCurrentRun(runVersion)) {
665
- this.currentTurnHandle = handle;
666
- } else {
667
- // Turn was superseded before handle arrived; abort immediately
668
- handle.abort();
669
- }
670
- })
671
- .catch((err) => {
672
- reject(err);
673
- });
585
+ .catch((err) => {
586
+ reject(err);
587
+ });
674
588
 
675
- // Defensive: if the turn is aborted (e.g. barge-in) and the event
676
- // sink callbacks are never invoked, resolve the promise so it
677
- // doesn't hang forever.
678
- runSignal.addEventListener(
679
- "abort",
680
- () => {
681
- resolve();
682
- },
683
- { once: true },
684
- );
685
- });
589
+ // Defensive: if the turn is aborted (e.g. barge-in) and the event
590
+ // sink callbacks are never invoked, resolve the promise so it
591
+ // doesn't hang forever.
592
+ runSignal.addEventListener(
593
+ "abort",
594
+ () => {
595
+ resolve();
596
+ },
597
+ { once: true },
598
+ );
599
+ });
686
600
 
687
- // Eagerly mark the rejection as handled so runtimes (e.g. bun) don't
688
- // flag it as an unhandled rejection when onError fires synchronously
689
- // inside the Promise constructor before this await adds its handler.
690
- // The await below still re-throws, caught by the outer try-catch.
691
- turnComplete.catch(() => {});
692
- await turnComplete;
693
- if (!this.isCurrentRun(runVersion)) return;
601
+ // Eagerly mark the rejection as handled so runtimes (e.g. bun) don't
602
+ // flag it as an unhandled rejection when onError fires synchronously
603
+ // inside the Promise constructor before this await adds its handler.
604
+ // The await below still re-throws, caught by the outer try-catch.
605
+ turnComplete.catch(() => {});
606
+ await turnComplete;
607
+ if (!this.isCurrentRun(runVersion)) return fullResponseText;
608
+
609
+ // Final sweep: strip any remaining control markers from the buffer
610
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
611
+ if (ttsBuffer.length > 0) {
612
+ this.relay.sendTextToken(ttsBuffer, false);
613
+ }
694
614
 
695
- // Final sweep: strip any remaining control markers from the buffer
696
- ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
697
- if (ttsBuffer.length > 0) {
698
- this.relay.sendTextToken(ttsBuffer, false);
699
- }
615
+ // Signal end of this turn's speech
616
+ this.relay.sendTextToken("", true);
700
617
 
701
- // Signal end of this turn's speech
702
- this.relay.sendTextToken("", true);
618
+ // Mark the greeting's first response as awaiting ack
619
+ if (this.lastSentWasOpener && fullResponseText.length > 0) {
620
+ this.awaitingOpeningAck = true;
621
+ this.lastSentWasOpener = false;
622
+ }
703
623
 
704
- // Mark the greeting's first response as awaiting ack
705
- if (this.lastSentWasOpener && fullResponseText.length > 0) {
706
- this.awaitingOpeningAck = true;
707
- this.lastSentWasOpener = false;
708
- }
624
+ return fullResponseText;
625
+ }
709
626
 
710
- const responseText = fullResponseText;
627
+ /**
628
+ * Handle post-turn marker detection and dispatch: guardian consultation
629
+ * (ASK_GUARDIAN_APPROVAL / ASK_GUARDIAN), call finalization (END_CALL),
630
+ * and normal idle transition.
631
+ */
632
+ private handleTurnCompletion(fullResponseText: string): void {
633
+ const responseText = fullResponseText;
711
634
 
712
- // Record the assistant response event
713
- recordCallEvent(this.callSessionId, "assistant_spoke", {
714
- text: responseText,
715
- });
716
- const spokenText = stripInternalSpeechMarkers(responseText).trim();
717
- if (spokenText.length > 0) {
718
- const session = getCallSession(this.callSessionId);
719
- if (session) {
720
- fireCallTranscriptNotifier(
721
- session.conversationId,
722
- this.callSessionId,
723
- "assistant",
724
- spokenText,
725
- );
726
- }
635
+ // Record the assistant response event
636
+ recordCallEvent(this.callSessionId, "assistant_spoke", {
637
+ text: responseText,
638
+ });
639
+ const spokenText = stripInternalSpeechMarkers(responseText).trim();
640
+ if (spokenText.length > 0) {
641
+ const session = getCallSession(this.callSessionId);
642
+ if (session) {
643
+ fireCallTranscriptNotifier(
644
+ session.conversationId,
645
+ this.callSessionId,
646
+ "assistant",
647
+ spokenText,
648
+ );
727
649
  }
650
+ }
728
651
 
729
- // Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
730
- // then informational ASK_GUARDIAN. Uses brace-balanced extraction so
731
- // `}]` inside JSON string values does not truncate the payload or
732
- // leak partial JSON into TTS output.
733
- const approvalMatch = extractBalancedJson(responseText);
734
- let toolApprovalMeta: {
735
- question: string;
736
- toolName: string;
737
- inputDigest: string;
738
- } | null = null;
739
- if (approvalMatch) {
740
- try {
741
- const parsed = JSON.parse(approvalMatch.json) as {
742
- question?: string;
743
- toolName?: string;
744
- input?: Record<string, unknown>;
745
- };
746
- if (parsed.question && parsed.toolName && parsed.input) {
747
- const digest = computeToolApprovalDigest(
748
- parsed.toolName,
749
- parsed.input,
750
- );
751
- toolApprovalMeta = {
752
- question: parsed.question,
753
- toolName: parsed.toolName,
754
- inputDigest: digest,
755
- };
756
- }
757
- } catch {
758
- log.warn(
759
- { callSessionId: this.callSessionId },
760
- "Failed to parse ASK_GUARDIAN_APPROVAL JSON payload",
652
+ // Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
653
+ // then informational ASK_GUARDIAN. Uses brace-balanced extraction so
654
+ // `}]` inside JSON string values does not truncate the payload or
655
+ // leak partial JSON into TTS output.
656
+ const approvalMatch = extractBalancedJson(responseText);
657
+ let toolApprovalMeta: {
658
+ question: string;
659
+ toolName: string;
660
+ inputDigest: string;
661
+ } | null = null;
662
+ if (approvalMatch) {
663
+ try {
664
+ const parsed = JSON.parse(approvalMatch.json) as {
665
+ question?: string;
666
+ toolName?: string;
667
+ input?: Record<string, unknown>;
668
+ };
669
+ if (parsed.question && parsed.toolName && parsed.input) {
670
+ const digest = computeToolApprovalDigest(
671
+ parsed.toolName,
672
+ parsed.input,
761
673
  );
674
+ toolApprovalMeta = {
675
+ question: parsed.question,
676
+ toolName: parsed.toolName,
677
+ inputDigest: digest,
678
+ };
762
679
  }
680
+ } catch {
681
+ log.warn(
682
+ { callSessionId: this.callSessionId },
683
+ "Failed to parse ASK_GUARDIAN_APPROVAL JSON payload",
684
+ );
763
685
  }
686
+ }
764
687
 
765
- const askMatch = toolApprovalMeta
766
- ? null // structured approval takes precedence
767
- : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
688
+ const askMatch = toolApprovalMeta
689
+ ? null // structured approval takes precedence
690
+ : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
768
691
 
769
- const questionText =
770
- toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
692
+ const questionText =
693
+ toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
771
694
 
772
- if (questionText) {
773
- if (this.isCallerGuardian()) {
774
- // Caller IS the guardian — don't dispatch cross-channel.
775
- // Queue an instruction so the next turn asks them directly.
776
- log.info(
777
- { callSessionId: this.callSessionId },
778
- "Caller is guardian — skipping ASK_GUARDIAN dispatch, asking directly",
779
- );
780
- this.pendingInstructions.push(
781
- `You just tried to use [ASK_GUARDIAN] but the person on the phone IS your guardian. Ask them directly: "${questionText}"`,
782
- );
783
- // Fall through to normal turn completion (idle + flushPendingInstructions)
784
- } else if (this.guardianUnavailableForCall) {
785
- // Guardian already timed out earlier in this call — skip the full
786
- // consultation wait and immediately tell the model to proceed
787
- // without guardian input.
788
- log.info(
789
- { callSessionId: this.callSessionId },
790
- "Guardian unavailable for call — skipping ASK_GUARDIAN wait",
791
- );
792
- recordCallEvent(this.callSessionId, "guardian_unavailable_skipped", {
793
- question: questionText,
794
- });
795
- this.pendingInstructions.push(
796
- `[GUARDIAN_UNAVAILABLE] You tried to consult your guardian again, but they were already unreachable earlier in this call. ` +
797
- `Do NOT use [ASK_GUARDIAN] again. Instead, let the caller know you cannot reach the guardian right now, ` +
798
- `and continue the conversation by asking if there is anything else you can help with or if they would like a callback. ` +
799
- `The unanswered question was: "${questionText}"`,
800
- );
801
- // Fall through to normal turn completion (idle + flushPendingInstructions)
802
- } else if (
803
- this.pendingInstructions.some((instr) =>
804
- instr.startsWith("[USER_ANSWERED:"),
805
- )
806
- ) {
807
- // A guardian answer arrived mid-turn and is queued in
808
- // pendingInstructions but hasn't been flushed yet. The in-flight
809
- // LLM response was generated without knowledge of this answer, so
810
- // creating a new consultation now would supersede the old one and
811
- // desynchronize the flow. Skip this consultation — the answer will
812
- // be flushed on the next turn, and if the model still needs to
813
- // consult a guardian, it will emit another ASK_GUARDIAN then.
814
- log.info(
815
- { callSessionId: this.callSessionId },
816
- "Deferring ASK_GUARDIAN — queued USER_ANSWERED pending",
817
- );
818
- recordCallEvent(this.callSessionId, "guardian_consult_deferred", {
819
- question: questionText,
820
- });
821
- // Fall through to normal turn completion (idle + flushPendingInstructions)
822
- } else {
823
- // Determine the effective tool metadata for this ask. If the new
824
- // ask has structured tool metadata, use it; otherwise inherit from
825
- // the prior pending consultation (preserves tool scope on re-asks).
826
- const effectiveToolMeta = toolApprovalMeta
827
- ? {
828
- toolName: toolApprovalMeta.toolName,
829
- inputDigest: toolApprovalMeta.inputDigest,
830
- }
831
- : (this.pendingGuardianInput?.toolApprovalMeta ?? null);
832
-
833
- // Coalesce repeated identical asks: if a consultation is already
834
- // pending for the same tool/action (or same informational question),
835
- // avoid churning requests and just keep the existing one.
836
- if (this.pendingGuardianInput) {
837
- const isSameToolAction =
838
- effectiveToolMeta && this.pendingGuardianInput.toolApprovalMeta
839
- ? effectiveToolMeta.toolName ===
840
- this.pendingGuardianInput.toolApprovalMeta.toolName &&
841
- effectiveToolMeta.inputDigest ===
842
- this.pendingGuardianInput.toolApprovalMeta.inputDigest
843
- : !effectiveToolMeta &&
844
- !this.pendingGuardianInput.toolApprovalMeta;
845
-
846
- if (isSameToolAction) {
847
- // Same tool/action — coalesce. Keep the existing consultation
848
- // alive and skip creating a new request.
695
+ if (questionText) {
696
+ if (this.isCallerGuardian()) {
697
+ // Caller IS the guardian — don't dispatch cross-channel.
698
+ // Queue an instruction so the next turn asks them directly.
699
+ log.info(
700
+ { callSessionId: this.callSessionId },
701
+ "Caller is guardian — skipping ASK_GUARDIAN dispatch, asking directly",
702
+ );
703
+ this.pendingInstructions.push(
704
+ `You just tried to use [ASK_GUARDIAN] but the person on the phone IS your guardian. Ask them directly: "${questionText}"`,
705
+ );
706
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
707
+ } else if (this.guardianUnavailableForCall) {
708
+ // Guardian already timed out earlier in this call — skip the full
709
+ // consultation wait and immediately tell the model to proceed
710
+ // without guardian input.
711
+ log.info(
712
+ { callSessionId: this.callSessionId },
713
+ "Guardian unavailable for call — skipping ASK_GUARDIAN wait",
714
+ );
715
+ recordCallEvent(this.callSessionId, "guardian_unavailable_skipped", {
716
+ question: questionText,
717
+ });
718
+ this.pendingInstructions.push(
719
+ `[GUARDIAN_UNAVAILABLE] You tried to consult your guardian again, but they were already unreachable earlier in this call. ` +
720
+ `Do NOT use [ASK_GUARDIAN] again. Instead, let the caller know you cannot reach the guardian right now, ` +
721
+ `and continue the conversation by asking if there is anything else you can help with or if they would like a callback. ` +
722
+ `The unanswered question was: "${questionText}"`,
723
+ );
724
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
725
+ } else if (
726
+ this.pendingInstructions.some((instr) =>
727
+ instr.startsWith("[USER_ANSWERED:"),
728
+ )
729
+ ) {
730
+ // A guardian answer arrived mid-turn and is queued in
731
+ // pendingInstructions but hasn't been flushed yet. The in-flight
732
+ // LLM response was generated without knowledge of this answer, so
733
+ // creating a new consultation now would supersede the old one and
734
+ // desynchronize the flow. Skip this consultation — the answer will
735
+ // be flushed on the next turn, and if the model still needs to
736
+ // consult a guardian, it will emit another ASK_GUARDIAN then.
737
+ log.info(
738
+ { callSessionId: this.callSessionId },
739
+ "Deferring ASK_GUARDIAN — queued USER_ANSWERED pending",
740
+ );
741
+ recordCallEvent(this.callSessionId, "guardian_consult_deferred", {
742
+ question: questionText,
743
+ });
744
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
745
+ } else {
746
+ // Determine the effective tool metadata for this ask. If the new
747
+ // ask has structured tool metadata, use it; otherwise inherit from
748
+ // the prior pending consultation (preserves tool scope on re-asks).
749
+ const effectiveToolMeta = toolApprovalMeta
750
+ ? {
751
+ toolName: toolApprovalMeta.toolName,
752
+ inputDigest: toolApprovalMeta.inputDigest,
753
+ }
754
+ : (this.pendingGuardianInput?.toolApprovalMeta ?? null);
755
+
756
+ // Coalesce repeated identical asks: if a consultation is already
757
+ // pending for the same tool/action (or same informational question),
758
+ // avoid churning requests and just keep the existing one.
759
+ if (this.pendingGuardianInput) {
760
+ const isSameToolAction =
761
+ effectiveToolMeta && this.pendingGuardianInput.toolApprovalMeta
762
+ ? effectiveToolMeta.toolName ===
763
+ this.pendingGuardianInput.toolApprovalMeta.toolName &&
764
+ effectiveToolMeta.inputDigest ===
765
+ this.pendingGuardianInput.toolApprovalMeta.inputDigest
766
+ : !effectiveToolMeta &&
767
+ !this.pendingGuardianInput.toolApprovalMeta;
768
+
769
+ if (isSameToolAction) {
770
+ // Same tool/action — coalesce. Keep the existing consultation
771
+ // alive and skip creating a new request.
772
+ log.info(
773
+ {
774
+ callSessionId: this.callSessionId,
775
+ questionId: this.pendingGuardianInput.questionId,
776
+ },
777
+ "Coalescing repeated ASK_GUARDIAN — same tool/action already pending",
778
+ );
779
+ recordCallEvent(this.callSessionId, "guardian_consult_coalesced", {
780
+ question: questionText,
781
+ });
782
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
783
+ } else {
784
+ // Materially different intent — supersede the old consultation.
785
+ clearTimeout(this.pendingGuardianInput.timer);
786
+
787
+ // Expire the previous consultation's storage records so stale
788
+ // guardian answers cannot match the old request.
789
+ expirePendingQuestions(this.callSessionId);
790
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(
791
+ this.callSessionId,
792
+ );
793
+ if (previousRequest) {
794
+ // Immediately expire with 'superseded' reason to prevent
795
+ // stale answers from resolving the old request.
796
+ expireCanonicalGuardianRequest(previousRequest.id);
849
797
  log.info(
850
798
  {
851
799
  callSessionId: this.callSessionId,
852
- questionId: this.pendingGuardianInput.questionId,
800
+ requestId: previousRequest.id,
853
801
  },
854
- "Coalescing repeated ASK_GUARDIAN same tool/action already pending",
855
- );
856
- recordCallEvent(
857
- this.callSessionId,
858
- "guardian_consult_coalesced",
859
- { question: questionText },
860
- );
861
- // Fall through to normal turn completion (idle + flushPendingInstructions)
862
- } else {
863
- // Materially different intent — supersede the old consultation.
864
- clearTimeout(this.pendingGuardianInput.timer);
865
-
866
- // Expire the previous consultation's storage records so stale
867
- // guardian answers cannot match the old request.
868
- expirePendingQuestions(this.callSessionId);
869
- const previousRequest = getPendingCanonicalRequestByCallSessionId(
870
- this.callSessionId,
871
- );
872
- if (previousRequest) {
873
- // Immediately expire with 'superseded' reason to prevent
874
- // stale answers from resolving the old request.
875
- expireCanonicalGuardianRequest(previousRequest.id);
876
- log.info(
877
- {
878
- callSessionId: this.callSessionId,
879
- requestId: previousRequest.id,
880
- },
881
- "Superseded guardian action request (materially different intent)",
882
- );
883
- }
884
-
885
- this.pendingGuardianInput = null;
886
-
887
- // Dispatch the new consultation with effective tool metadata.
888
- // The previous request ID is passed through so the dispatch
889
- // can backfill supersession chain metadata (superseded_by_request_id)
890
- // once the new request has been created.
891
- this.dispatchNewConsultation(
892
- questionText,
893
- effectiveToolMeta,
894
- previousRequest?.id ?? null,
802
+ "Superseded guardian action request (materially different intent)",
895
803
  );
896
804
  }
897
- } else {
898
- // No prior consultation — dispatch fresh
899
- this.dispatchNewConsultation(questionText, effectiveToolMeta, null);
805
+
806
+ this.pendingGuardianInput = null;
807
+
808
+ // Dispatch the new consultation with effective tool metadata.
809
+ // The previous request ID is passed through so the dispatch
810
+ // can backfill supersession chain metadata (superseded_by_request_id)
811
+ // once the new request has been created.
812
+ this.dispatchNewConsultation(
813
+ questionText,
814
+ effectiveToolMeta,
815
+ previousRequest?.id ?? null,
816
+ );
900
817
  }
818
+ } else {
819
+ // No prior consultation — dispatch fresh
820
+ this.dispatchNewConsultation(questionText, effectiveToolMeta, null);
901
821
  }
902
822
  }
823
+ }
903
824
 
904
- // Check for END_CALL marker
905
- if (responseText.includes(END_CALL_MARKER)) {
906
- // Clear any pending consultation before completing the call.
907
- // Without this, the consultation timeout can fire on an already-ended
908
- // call, overwriting 'completed' status back to 'in_progress' and
909
- // starting a new LLM turn on a dead session. Similarly, a late
910
- // handleUserAnswer could be accepted since pendingGuardianInput is
911
- // still non-null.
912
- if (this.pendingGuardianInput) {
913
- clearTimeout(this.pendingGuardianInput.timer);
914
-
915
- // Expire store-side consultation records so clients don't observe
916
- // a completed call with a dangling pendingQuestion, and guardian
917
- // replies are cleanly rejected instead of hitting answerCall failures.
918
- expirePendingQuestions(this.callSessionId);
919
- const previousRequest = getPendingCanonicalRequestByCallSessionId(
920
- this.callSessionId,
921
- );
922
- if (previousRequest) {
923
- expireCanonicalGuardianRequest(previousRequest.id);
924
- }
925
-
926
- this.pendingGuardianInput = null;
825
+ // Check for END_CALL marker
826
+ if (responseText.includes(END_CALL_MARKER)) {
827
+ // Clear any pending consultation before completing the call.
828
+ // Without this, the consultation timeout can fire on an already-ended
829
+ // call, overwriting 'completed' status back to 'in_progress' and
830
+ // starting a new LLM turn on a dead session. Similarly, a late
831
+ // handleUserAnswer could be accepted since pendingGuardianInput is
832
+ // still non-null.
833
+ if (this.pendingGuardianInput) {
834
+ clearTimeout(this.pendingGuardianInput.timer);
835
+
836
+ // Expire store-side consultation records so clients don't observe
837
+ // a completed call with a dangling pendingQuestion, and guardian
838
+ // replies are cleanly rejected instead of hitting answerCall failures.
839
+ expirePendingQuestions(this.callSessionId);
840
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(
841
+ this.callSessionId,
842
+ );
843
+ if (previousRequest) {
844
+ expireCanonicalGuardianRequest(previousRequest.id);
927
845
  }
928
846
 
929
- const currentSession = getCallSession(this.callSessionId);
930
- const shouldNotifyCompletion = currentSession
931
- ? currentSession.status !== "completed" &&
932
- currentSession.status !== "failed" &&
933
- currentSession.status !== "cancelled"
934
- : false;
935
-
936
- this.relay.endSession("Call completed");
937
- updateCallSession(this.callSessionId, {
938
- status: "completed",
939
- endedAt: Date.now(),
940
- });
941
- recordCallEvent(this.callSessionId, "call_ended", {
942
- reason: "completed",
943
- });
847
+ this.pendingGuardianInput = null;
848
+ }
944
849
 
945
- // Notify the voice conversation
946
- if (shouldNotifyCompletion && currentSession) {
947
- persistCallCompletionMessage(
948
- currentSession.conversationId,
949
- this.callSessionId,
950
- ).catch((err) => {
951
- log.error(
952
- {
953
- err,
954
- conversationId: currentSession.conversationId,
955
- callSessionId: this.callSessionId,
956
- },
957
- "Failed to persist call completion message",
958
- );
959
- });
960
- fireCallCompletionNotifier(
961
- currentSession.conversationId,
962
- this.callSessionId,
963
- );
964
- }
850
+ const currentSession = getCallSession(this.callSessionId);
851
+ const shouldNotifyCompletion = currentSession
852
+ ? currentSession.status !== "completed" &&
853
+ currentSession.status !== "failed" &&
854
+ currentSession.status !== "cancelled"
855
+ : false;
856
+
857
+ this.relay.endSession("Call completed");
858
+ updateCallSession(this.callSessionId, {
859
+ status: "completed",
860
+ endedAt: Date.now(),
861
+ });
862
+ recordCallEvent(this.callSessionId, "call_ended", {
863
+ reason: "completed",
864
+ });
965
865
 
966
- // Post a pointer message in the initiating conversation
967
- if (currentSession?.initiatedFromConversationId) {
968
- const durationMs = currentSession.startedAt
969
- ? Date.now() - currentSession.startedAt
970
- : 0;
971
- addPointerMessage(
972
- currentSession.initiatedFromConversationId,
973
- "completed",
974
- currentSession.toNumber,
975
- {
976
- duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
977
- },
978
- ).catch((err) => {
979
- log.warn(
980
- {
981
- conversationId: currentSession.initiatedFromConversationId,
982
- err,
983
- },
984
- "Skipping pointer write — origin conversation may no longer exist",
985
- );
986
- });
987
- }
988
- this.state = "idle";
989
- return;
866
+ // Notify the voice conversation
867
+ if (shouldNotifyCompletion && currentSession) {
868
+ finalizeCall(this.callSessionId, currentSession.conversationId);
990
869
  }
991
870
 
992
- // Normal turn complete restart silence detection and flush any
993
- // instructions that arrived while the LLM was active.
994
- this.state = "idle";
995
- this.currentTurnHandle = null;
996
- this.resetSilenceTimer();
997
- this.flushPendingInstructions();
998
- } catch (err: unknown) {
999
- this.currentTurnHandle = null;
1000
- // Aborted requests are expected (interruptions, rapid utterances)
1001
- if (this.isExpectedAbortError(err) || runSignal.aborted) {
1002
- log.debug(
871
+ // Post a pointer message in the initiating conversation
872
+ if (currentSession?.initiatedFromConversationId) {
873
+ const durationMs = currentSession.startedAt
874
+ ? Date.now() - currentSession.startedAt
875
+ : 0;
876
+ addPointerMessage(
877
+ currentSession.initiatedFromConversationId,
878
+ "completed",
879
+ currentSession.toNumber,
1003
880
  {
1004
- callSessionId: this.callSessionId,
1005
- errName: err instanceof Error ? err.name : typeof err,
1006
- stale: !this.isCurrentRun(runVersion),
881
+ duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
1007
882
  },
1008
- "Voice turn aborted",
1009
- );
1010
- if (this.isCurrentRun(runVersion)) {
1011
- this.state = "idle";
1012
- this.resetSilenceTimer();
1013
- }
1014
- return;
1015
- }
1016
- if (!this.isCurrentRun(runVersion)) {
1017
- log.debug(
1018
- {
1019
- callSessionId: this.callSessionId,
1020
- errName: err instanceof Error ? err.name : typeof err,
1021
- },
1022
- "Ignoring stale voice turn error from superseded turn",
1023
- );
1024
- return;
883
+ ).catch((err) => {
884
+ log.warn(
885
+ {
886
+ conversationId: currentSession.initiatedFromConversationId,
887
+ err,
888
+ },
889
+ "Skipping pointer write — origin conversation may no longer exist",
890
+ );
891
+ });
1025
892
  }
1026
- log.error({ err, callSessionId: this.callSessionId }, "Voice turn error");
1027
- this.relay.sendTextToken(
1028
- "I'm sorry, I encountered a technical issue. Could you repeat that?",
1029
- true,
1030
- );
1031
893
  this.state = "idle";
1032
- this.resetSilenceTimer();
1033
- this.flushPendingInstructions();
894
+ return;
1034
895
  }
896
+
897
+ // Normal turn complete — restart silence detection and flush any
898
+ // instructions that arrived while the LLM was active.
899
+ this.state = "idle";
900
+ this.currentTurnHandle = null;
901
+ this.resetSilenceTimer();
902
+ this.flushPendingInstructions();
1035
903
  }
1036
904
 
1037
905
  private isExpectedAbortError(err: unknown): boolean {
@@ -1268,23 +1136,7 @@ export class CallController {
1268
1136
  reason: "max_duration",
1269
1137
  });
1270
1138
  if (shouldNotifyCompletion && currentSession) {
1271
- persistCallCompletionMessage(
1272
- currentSession.conversationId,
1273
- this.callSessionId,
1274
- ).catch((err) => {
1275
- log.error(
1276
- {
1277
- err,
1278
- conversationId: currentSession.conversationId,
1279
- callSessionId: this.callSessionId,
1280
- },
1281
- "Failed to persist call completion message",
1282
- );
1283
- });
1284
- fireCallCompletionNotifier(
1285
- currentSession.conversationId,
1286
- this.callSessionId,
1287
- );
1139
+ finalizeCall(this.callSessionId, currentSession.conversationId);
1288
1140
  }
1289
1141
 
1290
1142
  // Post a pointer message in the initiating conversation