@kuralle-syrinx/core 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/dist/audio/alaw.d.ts +3 -0
  2. package/dist/audio/alaw.d.ts.map +1 -0
  3. package/dist/audio/alaw.js +53 -0
  4. package/dist/audio/alaw.js.map +1 -0
  5. package/dist/audio/g722.d.ts +39 -0
  6. package/dist/audio/g722.d.ts.map +1 -0
  7. package/dist/audio/g722.js +286 -0
  8. package/dist/audio/g722.js.map +1 -0
  9. package/dist/audio/index.d.ts +7 -0
  10. package/dist/audio/index.d.ts.map +1 -0
  11. package/dist/audio/index.js +8 -0
  12. package/dist/audio/index.js.map +1 -0
  13. package/dist/audio/loudness.d.ts +13 -0
  14. package/dist/audio/loudness.d.ts.map +1 -0
  15. package/dist/audio/loudness.js +65 -0
  16. package/dist/audio/loudness.js.map +1 -0
  17. package/dist/audio/mulaw.d.ts +3 -0
  18. package/dist/audio/mulaw.d.ts.map +1 -0
  19. package/dist/audio/mulaw.js +39 -0
  20. package/dist/audio/mulaw.js.map +1 -0
  21. package/dist/audio/pcm.d.ts +5 -0
  22. package/dist/audio/pcm.d.ts.map +1 -0
  23. package/dist/audio/pcm.js +40 -0
  24. package/dist/audio/pcm.js.map +1 -0
  25. package/dist/audio/resample.d.ts +12 -0
  26. package/dist/audio/resample.d.ts.map +1 -0
  27. package/dist/audio/resample.js +157 -0
  28. package/dist/audio/resample.js.map +1 -0
  29. package/dist/audio-envelope.d.ts +23 -0
  30. package/dist/audio-envelope.d.ts.map +1 -0
  31. package/dist/audio-envelope.js +114 -0
  32. package/dist/audio-envelope.js.map +1 -0
  33. package/dist/confidence-to-wait.d.ts +12 -0
  34. package/dist/confidence-to-wait.d.ts.map +1 -0
  35. package/dist/confidence-to-wait.js +18 -0
  36. package/dist/confidence-to-wait.js.map +1 -0
  37. package/dist/conversation-event.d.ts +19 -0
  38. package/dist/conversation-event.d.ts.map +1 -0
  39. package/dist/conversation-event.js +44 -0
  40. package/dist/conversation-event.js.map +1 -0
  41. package/dist/error-handler.d.ts +35 -0
  42. package/dist/error-handler.d.ts.map +1 -0
  43. package/dist/error-handler.js +136 -0
  44. package/dist/error-handler.js.map +1 -0
  45. package/dist/idle-timeout.d.ts +69 -0
  46. package/dist/idle-timeout.d.ts.map +1 -0
  47. package/dist/idle-timeout.js +149 -0
  48. package/dist/idle-timeout.js.map +1 -0
  49. package/dist/incremental-unit.d.ts +17 -0
  50. package/dist/incremental-unit.d.ts.map +1 -0
  51. package/dist/incremental-unit.js +5 -0
  52. package/dist/incremental-unit.js.map +1 -0
  53. package/dist/index.d.ts +47 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +61 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/init-chain.d.ts +35 -0
  58. package/dist/init-chain.d.ts.map +1 -0
  59. package/dist/init-chain.js +102 -0
  60. package/dist/init-chain.js.map +1 -0
  61. package/dist/init-stage-order.d.ts +5 -0
  62. package/dist/init-stage-order.d.ts.map +1 -0
  63. package/dist/init-stage-order.js +74 -0
  64. package/dist/init-stage-order.js.map +1 -0
  65. package/dist/interaction-coordinator.d.ts +45 -0
  66. package/dist/interaction-coordinator.d.ts.map +1 -0
  67. package/dist/interaction-coordinator.js +197 -0
  68. package/dist/interaction-coordinator.js.map +1 -0
  69. package/dist/interaction-policy.d.ts +103 -0
  70. package/dist/interaction-policy.d.ts.map +1 -0
  71. package/dist/interaction-policy.js +6 -0
  72. package/dist/interaction-policy.js.map +1 -0
  73. package/dist/iu-ledger.d.ts +39 -0
  74. package/dist/iu-ledger.d.ts.map +1 -0
  75. package/dist/iu-ledger.js +90 -0
  76. package/dist/iu-ledger.js.map +1 -0
  77. package/dist/latency-filler-fixtures.d.ts +7 -0
  78. package/dist/latency-filler-fixtures.d.ts.map +1 -0
  79. package/dist/latency-filler-fixtures.js +10 -0
  80. package/dist/latency-filler-fixtures.js.map +1 -0
  81. package/dist/latency-filler.d.ts +29 -0
  82. package/dist/latency-filler.d.ts.map +1 -0
  83. package/dist/latency-filler.js +102 -0
  84. package/dist/latency-filler.js.map +1 -0
  85. package/dist/mode-switcher.d.ts +24 -0
  86. package/dist/mode-switcher.d.ts.map +1 -0
  87. package/dist/mode-switcher.js +83 -0
  88. package/dist/mode-switcher.js.map +1 -0
  89. package/dist/observability-observer.d.ts +36 -0
  90. package/dist/observability-observer.d.ts.map +1 -0
  91. package/dist/observability-observer.js +141 -0
  92. package/dist/observability-observer.js.map +1 -0
  93. package/dist/observability.d.ts +68 -0
  94. package/dist/observability.d.ts.map +1 -0
  95. package/dist/observability.js +70 -0
  96. package/dist/observability.js.map +1 -0
  97. package/dist/packet-factories.d.ts +58 -0
  98. package/dist/packet-factories.d.ts.map +1 -0
  99. package/dist/packet-factories.js +181 -0
  100. package/dist/packet-factories.js.map +1 -0
  101. package/dist/packets.d.ts +532 -0
  102. package/dist/packets.d.ts.map +1 -0
  103. package/dist/packets.js +51 -0
  104. package/dist/packets.js.map +1 -0
  105. package/dist/pipeline-bus.d.ts +92 -0
  106. package/dist/pipeline-bus.d.ts.map +1 -0
  107. package/dist/pipeline-bus.js +294 -0
  108. package/dist/pipeline-bus.js.map +1 -0
  109. package/dist/plugin-contract.d.ts +73 -0
  110. package/dist/plugin-contract.d.ts.map +1 -0
  111. package/dist/plugin-contract.js +30 -0
  112. package/dist/plugin-contract.js.map +1 -0
  113. package/dist/policies/defer.d.ts +11 -0
  114. package/dist/policies/defer.d.ts.map +1 -0
  115. package/dist/policies/defer.js +13 -0
  116. package/dist/policies/defer.js.map +1 -0
  117. package/dist/policies/rule-based.d.ts +35 -0
  118. package/dist/policies/rule-based.d.ts.map +1 -0
  119. package/dist/policies/rule-based.js +130 -0
  120. package/dist/policies/rule-based.js.map +1 -0
  121. package/dist/pricing.d.ts +34 -0
  122. package/dist/pricing.d.ts.map +1 -0
  123. package/dist/pricing.js +108 -0
  124. package/dist/pricing.js.map +1 -0
  125. package/dist/primary-speaker-fixtures.d.ts +12 -0
  126. package/dist/primary-speaker-fixtures.d.ts.map +1 -0
  127. package/dist/primary-speaker-fixtures.js +37 -0
  128. package/dist/primary-speaker-fixtures.js.map +1 -0
  129. package/dist/primary-speaker-gate.d.ts +42 -0
  130. package/dist/primary-speaker-gate.d.ts.map +1 -0
  131. package/dist/primary-speaker-gate.js +178 -0
  132. package/dist/primary-speaker-gate.js.map +1 -0
  133. package/dist/provider-fallback.d.ts +28 -0
  134. package/dist/provider-fallback.d.ts.map +1 -0
  135. package/dist/provider-fallback.js +72 -0
  136. package/dist/provider-fallback.js.map +1 -0
  137. package/dist/reasoner-hedge.d.ts +21 -0
  138. package/dist/reasoner-hedge.d.ts.map +1 -0
  139. package/dist/reasoner-hedge.js +170 -0
  140. package/dist/reasoner-hedge.js.map +1 -0
  141. package/dist/reasoner-route.d.ts +24 -0
  142. package/dist/reasoner-route.d.ts.map +1 -0
  143. package/dist/reasoner-route.js +87 -0
  144. package/dist/reasoner-route.js.map +1 -0
  145. package/dist/reasoner-session-store.d.ts +18 -0
  146. package/dist/reasoner-session-store.d.ts.map +1 -0
  147. package/dist/reasoner-session-store.js +21 -0
  148. package/dist/reasoner-session-store.js.map +1 -0
  149. package/dist/reasoner.d.ts +97 -0
  150. package/dist/reasoner.d.ts.map +1 -0
  151. package/dist/reasoner.js +9 -0
  152. package/dist/reasoner.js.map +1 -0
  153. package/dist/retry.d.ts +31 -0
  154. package/dist/retry.d.ts.map +1 -0
  155. package/dist/retry.js +83 -0
  156. package/dist/retry.js.map +1 -0
  157. package/dist/scheduler.d.ts +11 -0
  158. package/dist/scheduler.d.ts.map +1 -0
  159. package/dist/scheduler.js +20 -0
  160. package/dist/scheduler.js.map +1 -0
  161. package/dist/spend-cap.d.ts +27 -0
  162. package/dist/spend-cap.d.ts.map +1 -0
  163. package/dist/spend-cap.js +38 -0
  164. package/dist/spend-cap.js.map +1 -0
  165. package/dist/tts-playout-clock.d.ts +43 -0
  166. package/dist/tts-playout-clock.d.ts.map +1 -0
  167. package/dist/tts-playout-clock.js +125 -0
  168. package/dist/tts-playout-clock.js.map +1 -0
  169. package/dist/turn-arbiter.d.ts +41 -0
  170. package/dist/turn-arbiter.d.ts.map +1 -0
  171. package/dist/turn-arbiter.js +259 -0
  172. package/dist/turn-arbiter.js.map +1 -0
  173. package/dist/voice-agent-session-util.d.ts +60 -0
  174. package/dist/voice-agent-session-util.d.ts.map +1 -0
  175. package/dist/voice-agent-session-util.js +204 -0
  176. package/dist/voice-agent-session-util.js.map +1 -0
  177. package/dist/voice-agent-session.d.ts +435 -0
  178. package/dist/voice-agent-session.d.ts.map +1 -0
  179. package/dist/voice-agent-session.js +1597 -0
  180. package/dist/voice-agent-session.js.map +1 -0
  181. package/dist/voice-text.d.ts +46 -0
  182. package/dist/voice-text.d.ts.map +1 -0
  183. package/dist/voice-text.js +209 -0
  184. package/dist/voice-text.js.map +1 -0
  185. package/package.json +19 -6
  186. package/src/audio/alaw.test.ts +87 -0
  187. package/src/audio/audio.test.ts +285 -0
  188. package/src/audio/g722.test.ts +149 -0
  189. package/src/audio/loudness.test.ts +58 -0
  190. package/src/audio-envelope.test.ts +167 -0
  191. package/src/confidence-to-wait.test.ts +21 -0
  192. package/src/error-handler.test.ts +56 -0
  193. package/src/hedge-throwing-backend.test.ts +46 -0
  194. package/src/interaction-coordinator.test.ts +529 -0
  195. package/src/iu-ledger.test.ts +276 -0
  196. package/src/latency-filler.test.ts +62 -0
  197. package/src/observability-observer.test.ts +248 -0
  198. package/src/observability.test.ts +94 -0
  199. package/src/packet-factories.test.ts +126 -0
  200. package/src/pipeline-bus.g10.test.ts +145 -0
  201. package/src/pipeline-bus.test.ts +211 -0
  202. package/src/policies/defer.test.ts +86 -0
  203. package/src/policies/rule-based.test.ts +334 -0
  204. package/src/pricing.test.ts +226 -0
  205. package/src/primary-speaker-gate.test.ts +150 -0
  206. package/src/provider-fallback.test.ts +87 -0
  207. package/src/reasoner-hedge.test.ts +361 -0
  208. package/src/reasoner-route.test.ts +248 -0
  209. package/src/reasoner.test.ts +69 -0
  210. package/src/retry.test.ts +83 -0
  211. package/src/route-throwing-spec.test.ts +44 -0
  212. package/src/spend-cap.test.ts +114 -0
  213. package/src/tts-playout-clock.test.ts +125 -0
  214. package/src/turn-arbiter.characterization.test.ts +477 -0
  215. package/src/turn-arbiter.test.ts +762 -0
  216. package/src/voice-agent-session.test.ts +4165 -0
  217. package/src/voice-text.test.ts +156 -0
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { ErrorCategory } from "./packets.js";
6
+ import { categorizeLlmError, categorizeSttError, categorizeTtsError, isRecoverable } from "./error-handler.js";
7
+
8
+ describe("LLM error handling", () => {
9
+ it("treats malformed provider tool calls as retryable generation failures", () => {
10
+ const category = categorizeLlmError(new Error("AI SDK provider step failed: MALFORMED_FUNCTION_CALL"));
11
+
12
+ expect(category).toBe(ErrorCategory.NetworkTimeout);
13
+ expect(isRecoverable(category)).toBe(true);
14
+ });
15
+
16
+ it("treats provider 500/internal TTS failures as retryable", () => {
17
+ const category = categorizeTtsError(new Error("code 500: An internal error has occurred. Please retry."));
18
+
19
+ expect(category).toBe(ErrorCategory.NetworkTimeout);
20
+ expect(isRecoverable(category)).toBe(true);
21
+ });
22
+
23
+ it("maps Deepgram NET websocket close frames to recoverable connection failures", () => {
24
+ const category = categorizeSttError(new Error("Deepgram STT WebSocket closed unexpectedly: code=1011 reason=NET-0000"));
25
+
26
+ expect(category).toBe(ErrorCategory.NetworkTimeout);
27
+ expect(isRecoverable(category)).toBe(true);
28
+ });
29
+
30
+ it("maps provider finalization timed-out wording to recoverable timeout", () => {
31
+ const category = categorizeSttError(new Error("Deepgram STT Finalize timed out before speech_final/from_finalize confirmation"));
32
+
33
+ expect(category).toBe(ErrorCategory.NetworkTimeout);
34
+ expect(isRecoverable(category)).toBe(true);
35
+ });
36
+
37
+ it("maps Deepgram DATA websocket close frames to fatal input failures", () => {
38
+ const category = categorizeSttError(new Error("Deepgram STT WebSocket closed unexpectedly: code=1008 reason=DATA-0000"));
39
+
40
+ expect(category).toBe(ErrorCategory.InvalidInput);
41
+ expect(isRecoverable(category)).toBe(false);
42
+ });
43
+
44
+ it("maps provider concurrency-limit errors to recoverable rate-limit (G8)", () => {
45
+ for (const message of [
46
+ "Cartesia TTS provider error: concurrency limit exceeded",
47
+ "Too many concurrent connections",
48
+ "max concurrency reached",
49
+ "concurrency_limit_exceeded",
50
+ ]) {
51
+ const category = categorizeTtsError(new Error(message));
52
+ expect(category, `message: ${message}`).toBe(ErrorCategory.RateLimit);
53
+ expect(isRecoverable(category)).toBe(true);
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Regression guard (RL-WBS-1): a backend whose next() REJECTS (throws) — e.g. an adapter that
3
+ // throws AbortError on barge-in — must NOT hang the hedge race; it fails over / surfaces an error.
4
+ import { describe, expect, it } from "vitest";
5
+ import { HedgedReasoner } from "./reasoner-hedge.js";
6
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
7
+ import type { Scheduler } from "./scheduler.js";
8
+
9
+ class ThrowingReasoner implements Reasoner {
10
+ async *stream(_turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
11
+ throw new Error("backend boom"); // rejects on first next()
12
+ }
13
+ }
14
+ class GoodReasoner implements Reasoner {
15
+ async *stream(_turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
16
+ yield { type: "text-delta", text: "backup ok" };
17
+ yield { type: "finish", reason: "stop", text: "backup ok" };
18
+ }
19
+ }
20
+ // scheduler that fires immediately so backup is available
21
+ const eagerScheduler: Scheduler = { schedule: (_k, _ms, cb) => cb(), cancel: () => {} };
22
+
23
+ function turn(): ReasonerTurn {
24
+ return { userText: "hi", messages: [], signal: new AbortController().signal };
25
+ }
26
+
27
+ describe("hedge reject repro", () => {
28
+ it("does not hang when the primary backend THROWS (rejects next())", async () => {
29
+ const hedged = new HedgedReasoner({
30
+ primary: new ThrowingReasoner(),
31
+ backup: new GoodReasoner(),
32
+ hedgeAfterMs: 0,
33
+ scheduler: eagerScheduler,
34
+ });
35
+ const parts: string[] = [];
36
+ const collect = (async () => {
37
+ for await (const p of hedged.stream(turn())) {
38
+ if (p.type === "text-delta") parts.push(p.text);
39
+ }
40
+ })();
41
+ const timeout = new Promise((_r, rej) => setTimeout(() => rej(new Error("HUNG: HedgedReasoner never completed")), 3000));
42
+ await Promise.race([collect, timeout]);
43
+ // If it doesn't hang, it should have failed over to the backup.
44
+ expect(parts).toContain("backup ok");
45
+ });
46
+ });
@@ -0,0 +1,529 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { PipelineBusImpl, Route } from "./pipeline-bus.js";
5
+ import { InteractionCoordinator } from "./interaction-coordinator.js";
6
+ import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "./interaction-policy.js";
7
+ import { TurnArbiter } from "./turn-arbiter.js";
8
+ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
9
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
10
+ import { TimerScheduler } from "./scheduler.js";
11
+ import type { EndOfSpeechPacket, InteractionBackchannelPacket, InterruptionDetectedPacket } from "./packets.js";
12
+
13
+ class StubPolicy implements InteractionPolicy {
14
+ constructor(private readonly decisions: InteractionDecision[]) {}
15
+
16
+ observe(_obs: InteractionObservation): readonly InteractionDecision[] {
17
+ return this.decisions;
18
+ }
19
+
20
+ reset(): void {}
21
+ }
22
+
23
+ class StatefulPolicy implements InteractionPolicy {
24
+ private step = 0;
25
+
26
+ constructor(private readonly steps: Array<readonly InteractionDecision[]>) {}
27
+
28
+ observe(_obs: InteractionObservation): readonly InteractionDecision[] {
29
+ const out = this.steps[this.step] ?? [];
30
+ this.step += 1;
31
+ return out;
32
+ }
33
+
34
+ reset(): void {
35
+ this.step = 0;
36
+ }
37
+ }
38
+
39
+ async function createCoordinator(
40
+ decisions: InteractionDecision[],
41
+ options: {
42
+ emitsBackchannel?: boolean;
43
+ isUserSpeaking?: () => boolean;
44
+ isTtsActive?: () => boolean;
45
+ hasCueAsset?: (cueId: string) => boolean;
46
+ endpointingOwner?: EndOfSpeechPacket["endpointingOwner"];
47
+ wasForceFinalized?: (contextId: string) => boolean;
48
+ } = {},
49
+ ) {
50
+ const bus = new PipelineBusImpl();
51
+ void bus.start();
52
+ const ttsPlayout = new TtsPlayoutClock();
53
+ const executor = new TurnArbiter({
54
+ bus,
55
+ primarySpeakerGate: new PrimarySpeakerGate(),
56
+ ttsPlayout,
57
+ minInterruptionMs: 280,
58
+ });
59
+ const coordinator = new InteractionCoordinator({
60
+ bus,
61
+ policy: new StubPolicy(decisions),
62
+ executor,
63
+ scheduler: new TimerScheduler(),
64
+ caps: { emitsBackchannel: options.emitsBackchannel },
65
+ isUserSpeaking: options.isUserSpeaking,
66
+ isTtsActive: options.isTtsActive,
67
+ hasCueAsset: options.hasCueAsset,
68
+ endpointingOwner: options.endpointingOwner,
69
+ wasForceFinalized: options.wasForceFinalized,
70
+ });
71
+ coordinator.initialize();
72
+ return { bus, coordinator, executor };
73
+ }
74
+
75
+ async function drainBus(): Promise<void> {
76
+ await new Promise((resolve) => setTimeout(resolve, 0));
77
+ }
78
+
79
+ function metricNames(bus: PipelineBusImpl): string[] {
80
+ const names: string[] = [];
81
+ bus.on("metric.conversation", (pkt) => {
82
+ names.push((pkt as unknown as { name: string }).name);
83
+ });
84
+ return names;
85
+ }
86
+
87
+ describe("InteractionCoordinator", () => {
88
+ it("maps interrupt decision to executor.emitInterruptDetected", async () => {
89
+ const { bus, coordinator, executor } = await createCoordinator([
90
+ { kind: "interrupt", interruptedContextId: "assistant-turn" },
91
+ ]);
92
+ const interrupts: InterruptionDetectedPacket[] = [];
93
+ bus.on("interrupt.detected", (pkt) => {
94
+ interrupts.push(pkt as InterruptionDetectedPacket);
95
+ });
96
+ const emitSpy = vi.spyOn(executor, "emitInterruptDetected");
97
+
98
+ coordinator.observe({
99
+ kind: "vad_speech_activity",
100
+ contextId: "user",
101
+ timestampMs: 1000,
102
+ });
103
+ await drainBus();
104
+
105
+ expect(emitSpy).toHaveBeenCalledOnce();
106
+ expect(emitSpy).toHaveBeenCalledWith("assistant-turn");
107
+ expect(interrupts).toHaveLength(1);
108
+ expect(interrupts[0]!.contextId).toBe("assistant-turn");
109
+ });
110
+
111
+ it("emits interaction.backchannel for a backchannel decision", async () => {
112
+ const { bus, coordinator } = await createCoordinator([{ kind: "backchannel", cue: "mm_hmm" }]);
113
+ const packets: InteractionBackchannelPacket[] = [];
114
+ const metrics = metricNames(bus);
115
+ bus.on("interaction.backchannel", (pkt) => {
116
+ packets.push(pkt as InteractionBackchannelPacket);
117
+ });
118
+
119
+ coordinator.observe({
120
+ kind: "delegate_state",
121
+ contextId: "turn-1",
122
+ timestampMs: 2000,
123
+ toolCallPhase: "delayed",
124
+ });
125
+ await drainBus();
126
+
127
+ expect(packets).toHaveLength(1);
128
+ expect(packets[0]).toMatchObject({ contextId: "turn-1", cue: "mm_hmm" });
129
+ expect(metrics).toContain("backchannel.candidate");
130
+ expect(metrics).toContain("backchannel.emitted");
131
+ });
132
+
133
+ it("suppresses backchannel when caps.emitsBackchannel is true", async () => {
134
+ const { bus, coordinator } = await createCoordinator(
135
+ [{ kind: "backchannel", cue: "mm_hmm" }],
136
+ { emitsBackchannel: true },
137
+ );
138
+ const packets: InteractionBackchannelPacket[] = [];
139
+ const metrics = metricNames(bus);
140
+ bus.on("interaction.backchannel", (pkt) => {
141
+ packets.push(pkt as InteractionBackchannelPacket);
142
+ });
143
+
144
+ coordinator.observe({
145
+ kind: "delegate_state",
146
+ contextId: "turn-1",
147
+ timestampMs: 2000,
148
+ toolCallPhase: "delayed",
149
+ });
150
+ await drainBus();
151
+
152
+ expect(packets).toEqual([]);
153
+ expect(metrics).toContain("backchannel.suppressed_caps");
154
+ });
155
+
156
+ it("suppresses backchannel when TTS is active", async () => {
157
+ const { bus, coordinator } = await createCoordinator(
158
+ [{ kind: "backchannel", cue: "mm_hmm" }],
159
+ { isTtsActive: () => true },
160
+ );
161
+ const metrics = metricNames(bus);
162
+
163
+ coordinator.observe({
164
+ kind: "delegate_state",
165
+ contextId: "turn-1",
166
+ timestampMs: 2000,
167
+ toolCallPhase: "delayed",
168
+ });
169
+ await drainBus();
170
+
171
+ expect(metrics).toContain("backchannel.suppressed_tts_active");
172
+ });
173
+
174
+ it("suppresses backchannel when the user is speaking", async () => {
175
+ const { bus, coordinator } = await createCoordinator(
176
+ [{ kind: "backchannel", cue: "mm_hmm" }],
177
+ { isUserSpeaking: () => true },
178
+ );
179
+ const metrics = metricNames(bus);
180
+
181
+ coordinator.observe({
182
+ kind: "delegate_state",
183
+ contextId: "turn-1",
184
+ timestampMs: 2000,
185
+ toolCallPhase: "delayed",
186
+ });
187
+ await drainBus();
188
+
189
+ expect(metrics).toContain("backchannel.suppressed_user_speaking");
190
+ });
191
+
192
+ it("suppresses backchannel when the cue asset is missing", async () => {
193
+ const { bus, coordinator } = await createCoordinator(
194
+ [{ kind: "backchannel", cue: "mm_hmm" }],
195
+ { hasCueAsset: () => false },
196
+ );
197
+ const metrics = metricNames(bus);
198
+
199
+ coordinator.observe({
200
+ kind: "delegate_state",
201
+ contextId: "turn-1",
202
+ timestampMs: 2000,
203
+ toolCallPhase: "delayed",
204
+ });
205
+ await drainBus();
206
+
207
+ expect(metrics).toContain("backchannel.suppressed_missing_asset");
208
+ });
209
+
210
+ it("maps take_turn to stt.finalize and a delayed eos.turn_complete", async () => {
211
+ vi.useFakeTimers();
212
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
213
+ const finalizeRequests: string[] = [];
214
+ const completions: Array<{ contextId: string; text: string }> = [];
215
+ bus.on("stt.finalize", (pkt) => {
216
+ finalizeRequests.push(pkt.contextId);
217
+ });
218
+ bus.on("eos.turn_complete", (pkt) => {
219
+ const eos = pkt as EndOfSpeechPacket;
220
+ completions.push({ contextId: eos.contextId, text: eos.text });
221
+ });
222
+
223
+ coordinator.observe({
224
+ kind: "vad_speech_ended",
225
+ contextId: "turn-1",
226
+ timestampMs: 1000,
227
+ hasActiveTts: false,
228
+ });
229
+ bus.push(Route.Main, {
230
+ kind: "stt.result",
231
+ contextId: "turn-1",
232
+ timestampMs: 1100,
233
+ text: "hello there",
234
+ confidence: 0.95,
235
+ language: "en-US",
236
+ });
237
+ await vi.advanceTimersByTimeAsync(0);
238
+ expect(finalizeRequests).toEqual(["turn-1"]);
239
+ expect(completions).toEqual([]);
240
+
241
+ await vi.advanceTimersByTimeAsync(149);
242
+ expect(completions).toEqual([]);
243
+
244
+ await vi.advanceTimersByTimeAsync(1);
245
+ expect(completions).toEqual([{ contextId: "turn-1", text: "hello there" }]);
246
+ vi.useRealTimers();
247
+ });
248
+
249
+ it("uses a transcript that arrived before take_turn and requests STT finalization only once", async () => {
250
+ vi.useFakeTimers();
251
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
252
+ const finalizeRequests: string[] = [];
253
+ const completions: EndOfSpeechPacket[] = [];
254
+ bus.on("stt.finalize", (pkt) => {
255
+ finalizeRequests.push(pkt.contextId);
256
+ });
257
+ bus.on("eos.turn_complete", (pkt) => {
258
+ completions.push(pkt as EndOfSpeechPacket);
259
+ });
260
+
261
+ bus.push(Route.Main, {
262
+ kind: "stt.result",
263
+ contextId: "turn-cached",
264
+ timestampMs: 900,
265
+ text: "already final",
266
+ confidence: 0.95,
267
+ language: "en-US",
268
+ });
269
+ await vi.advanceTimersByTimeAsync(0);
270
+ const observation = {
271
+ kind: "audio_frame" as const,
272
+ contextId: "turn-cached",
273
+ timestampMs: 1000,
274
+ audio: new Int16Array(320),
275
+ };
276
+ coordinator.observe(observation);
277
+ coordinator.observe(observation);
278
+
279
+ await vi.advanceTimersByTimeAsync(0);
280
+ expect(finalizeRequests).toEqual(["turn-cached"]);
281
+ await vi.advanceTimersByTimeAsync(150);
282
+ expect(completions).toEqual([
283
+ expect.objectContaining({ contextId: "turn-cached", text: "already final" }),
284
+ ]);
285
+ vi.useRealTimers();
286
+ });
287
+
288
+ it("completes a committed take_turn from the latest interim when no final STT ever arrives", async () => {
289
+ vi.useFakeTimers();
290
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
291
+ const finalizeRequests: string[] = [];
292
+ const completions: EndOfSpeechPacket[] = [];
293
+ bus.on("stt.finalize", (pkt) => {
294
+ finalizeRequests.push(pkt.contextId);
295
+ });
296
+ bus.on("eos.turn_complete", (pkt) => {
297
+ completions.push(pkt as EndOfSpeechPacket);
298
+ });
299
+
300
+ coordinator.observe({
301
+ kind: "vad_speech_ended",
302
+ contextId: "turn-interim",
303
+ timestampMs: 1000,
304
+ hasActiveTts: false,
305
+ });
306
+ // Only an interim arrives — the provider never emits a final stt.result.
307
+ bus.push(Route.Main, {
308
+ kind: "stt.interim",
309
+ contextId: "turn-interim",
310
+ timestampMs: 1100,
311
+ text: "i wanted to ask",
312
+ });
313
+ await vi.advanceTimersByTimeAsync(0);
314
+ expect(finalizeRequests).toEqual(["turn-interim"]);
315
+ expect(completions).toEqual([]);
316
+
317
+ // High confidence → 150ms wait; the committed turn must NOT be dropped.
318
+ await vi.advanceTimersByTimeAsync(150);
319
+ expect(completions).toHaveLength(1);
320
+ expect(completions[0]).toMatchObject({ contextId: "turn-interim", text: "i wanted to ask" });
321
+ expect(completions[0]!.transcripts).toHaveLength(1);
322
+ vi.useRealTimers();
323
+ });
324
+
325
+ it("does not complete a take_turn when neither a final nor an interim ever arrives", async () => {
326
+ vi.useFakeTimers();
327
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
328
+ const completions: string[] = [];
329
+ bus.on("eos.turn_complete", (pkt) => {
330
+ completions.push(pkt.contextId);
331
+ });
332
+
333
+ coordinator.observe({
334
+ kind: "vad_speech_ended",
335
+ contextId: "turn-empty",
336
+ timestampMs: 1000,
337
+ hasActiveTts: false,
338
+ });
339
+ await vi.advanceTimersByTimeAsync(5000);
340
+ expect(completions).toEqual([]);
341
+ vi.useRealTimers();
342
+ });
343
+
344
+ it("revokes a pending take_turn on hold", async () => {
345
+ vi.useFakeTimers();
346
+ const bus = new PipelineBusImpl();
347
+ void bus.start();
348
+ const coordinator = new InteractionCoordinator({
349
+ bus,
350
+ policy: new StatefulPolicy([
351
+ [{ kind: "take_turn", confidence: 0.2 }],
352
+ [{ kind: "hold" }],
353
+ ]),
354
+ executor: new TurnArbiter({
355
+ bus,
356
+ primarySpeakerGate: new PrimarySpeakerGate(),
357
+ ttsPlayout: new TtsPlayoutClock(),
358
+ minInterruptionMs: 280,
359
+ }),
360
+ scheduler: new TimerScheduler(),
361
+ caps: {},
362
+ });
363
+ coordinator.initialize();
364
+
365
+ const completions: string[] = [];
366
+ bus.on("eos.turn_complete", (pkt) => {
367
+ completions.push(pkt.contextId);
368
+ });
369
+
370
+ coordinator.observe({
371
+ kind: "vad_speech_ended",
372
+ contextId: "turn-hold",
373
+ timestampMs: 1000,
374
+ hasActiveTts: false,
375
+ });
376
+ bus.push(Route.Main, {
377
+ kind: "stt.result",
378
+ contextId: "turn-hold",
379
+ timestampMs: 1100,
380
+ text: "wait",
381
+ confidence: 0.9,
382
+ language: "en-US",
383
+ });
384
+ await vi.advanceTimersByTimeAsync(0);
385
+ coordinator.observe({
386
+ kind: "vad_speech_activity",
387
+ contextId: "turn-hold",
388
+ timestampMs: 1200,
389
+ });
390
+
391
+ await vi.advanceTimersByTimeAsync(5000);
392
+ expect(completions).toEqual([]);
393
+ vi.useRealTimers();
394
+ });
395
+
396
+ it("revokes a pending take_turn when speech resumes", async () => {
397
+ vi.useFakeTimers();
398
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 0.5 }]);
399
+ const completions: string[] = [];
400
+ bus.on("eos.turn_complete", (pkt) => {
401
+ completions.push(pkt.contextId);
402
+ });
403
+
404
+ coordinator.observe({
405
+ kind: "vad_speech_ended",
406
+ contextId: "turn-resume",
407
+ timestampMs: 1000,
408
+ hasActiveTts: false,
409
+ });
410
+ bus.push(Route.Main, {
411
+ kind: "stt.result",
412
+ contextId: "turn-resume",
413
+ timestampMs: 1100,
414
+ text: "partial",
415
+ confidence: 0.9,
416
+ language: "en-US",
417
+ });
418
+ await vi.advanceTimersByTimeAsync(0);
419
+ bus.push(Route.Main, {
420
+ kind: "vad.speech_started",
421
+ contextId: "turn-resume",
422
+ timestampMs: 1200,
423
+ confidence: 0.9,
424
+ });
425
+ await vi.advanceTimersByTimeAsync(5000);
426
+ expect(completions).toEqual([]);
427
+ vi.useRealTimers();
428
+ });
429
+ });
430
+
431
+ describe("InteractionCoordinator — endpointing decision on eos", () => {
432
+ it("labels the completing eos with the session owner and a natural-end reason", async () => {
433
+ vi.useFakeTimers();
434
+ const { bus, coordinator } = await createCoordinator(
435
+ [{ kind: "take_turn", confidence: 1 }],
436
+ { endpointingOwner: "smart_turn" },
437
+ );
438
+ const completions: EndOfSpeechPacket[] = [];
439
+ bus.on("eos.turn_complete", (pkt) => {
440
+ completions.push(pkt as EndOfSpeechPacket);
441
+ });
442
+
443
+ coordinator.observe({
444
+ kind: "vad_speech_ended",
445
+ contextId: "turn-owner",
446
+ timestampMs: 1000,
447
+ hasActiveTts: false,
448
+ });
449
+ bus.push(Route.Main, {
450
+ kind: "stt.result",
451
+ contextId: "turn-owner",
452
+ timestampMs: 1100,
453
+ text: "hi",
454
+ confidence: 0.9,
455
+ language: "en-US",
456
+ });
457
+ await vi.advanceTimersByTimeAsync(200);
458
+
459
+ expect(completions).toHaveLength(1);
460
+ expect(completions[0]!.endpointingOwner).toBe("smart_turn");
461
+ expect(completions[0]!.endpointingReason).toBe("end_of_speech");
462
+ vi.useRealTimers();
463
+ });
464
+
465
+ it("labels the reason force_finalized when the STT watchdog already fired", async () => {
466
+ vi.useFakeTimers();
467
+ const { bus, coordinator } = await createCoordinator(
468
+ [{ kind: "take_turn", confidence: 1 }],
469
+ { endpointingOwner: "provider_stt", wasForceFinalized: (cid) => cid === "turn-forced" },
470
+ );
471
+ const completions: EndOfSpeechPacket[] = [];
472
+ bus.on("eos.turn_complete", (pkt) => {
473
+ completions.push(pkt as EndOfSpeechPacket);
474
+ });
475
+
476
+ coordinator.observe({
477
+ kind: "vad_speech_ended",
478
+ contextId: "turn-forced",
479
+ timestampMs: 1000,
480
+ hasActiveTts: false,
481
+ });
482
+ bus.push(Route.Main, {
483
+ kind: "stt.result",
484
+ contextId: "turn-forced",
485
+ timestampMs: 1100,
486
+ text: "hi",
487
+ confidence: 0.9,
488
+ language: "en-US",
489
+ });
490
+ await vi.advanceTimersByTimeAsync(200);
491
+
492
+ expect(completions).toHaveLength(1);
493
+ expect(completions[0]!.endpointingOwner).toBe("provider_stt");
494
+ expect(completions[0]!.endpointingReason).toBe("force_finalized");
495
+ vi.useRealTimers();
496
+ });
497
+
498
+ it("omits the decision when no owner is configured (unknown stays unknown)", async () => {
499
+ vi.useFakeTimers();
500
+ const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
501
+ const completions: EndOfSpeechPacket[] = [];
502
+ bus.on("eos.turn_complete", (pkt) => {
503
+ completions.push(pkt as EndOfSpeechPacket);
504
+ });
505
+
506
+ coordinator.observe({
507
+ kind: "vad_speech_ended",
508
+ contextId: "turn-unknown",
509
+ timestampMs: 1000,
510
+ hasActiveTts: false,
511
+ });
512
+ bus.push(Route.Main, {
513
+ kind: "stt.result",
514
+ contextId: "turn-unknown",
515
+ timestampMs: 1100,
516
+ text: "hi",
517
+ confidence: 0.9,
518
+ language: "en-US",
519
+ });
520
+ await vi.advanceTimersByTimeAsync(200);
521
+
522
+ expect(completions).toHaveLength(1);
523
+ // No owner configured → the decision is genuinely unknown, so it is omitted
524
+ // rather than fabricated.
525
+ expect(completions[0]!.endpointingOwner).toBeUndefined();
526
+ expect(completions[0]!.endpointingReason).toBeUndefined();
527
+ vi.useRealTimers();
528
+ });
529
+ });