@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,126 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ callTransfer,
7
+ dtmfSend,
8
+ injectMessage,
9
+ reasoningResume,
10
+ reasoningSuspended,
11
+ sttPartial,
12
+ sttReconfigure,
13
+ } from "./packet-factories.js";
14
+
15
+ describe("sttPartial", () => {
16
+ it("returns a stt.partial packet with optional wordTimings", () => {
17
+ const timings = [{ word: "hello", startMs: 100, endMs: 250, confidence: 0.9 }];
18
+ expect(sttPartial("ctx-1", 1000, "hello world", timings)).toEqual({
19
+ kind: "stt.partial",
20
+ contextId: "ctx-1",
21
+ timestampMs: 1000,
22
+ text: "hello world",
23
+ wordTimings: timings,
24
+ });
25
+ expect(sttPartial("ctx-1", 1000, "hello")).toEqual({
26
+ kind: "stt.partial",
27
+ contextId: "ctx-1",
28
+ timestampMs: 1000,
29
+ text: "hello",
30
+ });
31
+ });
32
+ });
33
+
34
+ describe("reasoningSuspended", () => {
35
+ it("returns a reasoning.suspended packet with the expected shape", () => {
36
+ const pkt = reasoningSuspended("ctx-1", 1234, "run-1", { step: 3 }, "Pause for input.");
37
+
38
+ expect(pkt).toEqual({
39
+ kind: "reasoning.suspended",
40
+ contextId: "ctx-1",
41
+ timestampMs: 1234,
42
+ runId: "run-1",
43
+ prompt: "Pause for input.",
44
+ payload: { step: 3 },
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("reasoningResume", () => {
50
+ it("returns a reasoning.resume packet with the expected shape", () => {
51
+ const pkt = reasoningResume("ctx-1", 5678, "run-1", "user answer");
52
+
53
+ expect(pkt).toEqual({
54
+ kind: "reasoning.resume",
55
+ contextId: "ctx-1",
56
+ timestampMs: 5678,
57
+ runId: "run-1",
58
+ data: "user answer",
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("injectMessage", () => {
64
+ it("keeps speak as the omitted default and carries context mode when requested", () => {
65
+ expect(injectMessage("ctx-1", 1000, "say this")).toEqual({
66
+ kind: "inject.message",
67
+ contextId: "ctx-1",
68
+ timestampMs: 1000,
69
+ text: "say this",
70
+ });
71
+ expect(injectMessage("ctx-1", 1000, "remember this", "context")).toEqual({
72
+ kind: "inject.message",
73
+ contextId: "ctx-1",
74
+ timestampMs: 1000,
75
+ text: "remember this",
76
+ mode: "context",
77
+ });
78
+ });
79
+ });
80
+
81
+ describe("sttReconfigure", () => {
82
+ it("returns an stt.reconfigure packet with the partial", () => {
83
+ const partial = { keyterms: ["account number"], endpointingMs: 120 };
84
+ expect(sttReconfigure("ctx-1", 1000, partial)).toEqual({
85
+ kind: "stt.reconfigure",
86
+ contextId: "ctx-1",
87
+ timestampMs: 1000,
88
+ partial,
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("dtmfSend", () => {
94
+ it("accepts digits with pause syntax", () => {
95
+ expect(dtmfSend("ctx", 1, "1w2W9*#")).toEqual({
96
+ kind: "dtmf.send",
97
+ contextId: "ctx",
98
+ timestampMs: 1,
99
+ digits: "1w2W9*#",
100
+ });
101
+ });
102
+
103
+ it("rejects invalid digit characters", () => {
104
+ expect(() => dtmfSend("ctx", 1, "12a")).toThrow(/Invalid dtmf.send/);
105
+ expect(() => dtmfSend("ctx", 1, "")).toThrow(/Invalid dtmf.send/);
106
+ });
107
+ });
108
+
109
+ describe("callTransfer", () => {
110
+ it("builds warm/cold/sip_refer packets", () => {
111
+ expect(callTransfer("ctx", 2, "warm", "+15551234567", "billing")).toEqual({
112
+ kind: "call.transfer",
113
+ contextId: "ctx",
114
+ timestampMs: 2,
115
+ mode: "warm",
116
+ target: "+15551234567",
117
+ summary: "billing",
118
+ });
119
+ expect(callTransfer("ctx", 2, "cold", "sip:x@y").summary).toBeUndefined();
120
+ expect(callTransfer("ctx", 2, "sip_refer", "sip:agent@pbx").mode).toBe("sip_refer");
121
+ });
122
+
123
+ it("rejects empty target", () => {
124
+ expect(() => callTransfer("ctx", 1, "cold", " ")).toThrow(/target/);
125
+ });
126
+ });
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // G10 repro — bus head-of-line blocking.
4
+ //
5
+ // The drain loop awaits each sync handler before dequeuing the next batch
6
+ // (pipeline-bus.ts: `await this.dispatch(entry.packet)`), and dispatch awaits
7
+ // sync handlers serially. So a long-running sync Main handler (in production: the
8
+ // AI-SDK bridge running a multi-second LLM generation on `eos.turn_complete`)
9
+ // parks the loop — and a Critical `interrupt.detected` pushed during that window
10
+ // is NOT dispatched until the slow handler returns. That delays barge-in (and
11
+ // defers the llm.delta -> tts.text streaming the slow handler itself produces).
12
+ //
13
+ // These tests assert the DESIRED behavior: Critical packets are dispatched
14
+ // promptly even while a slow Main handler is in flight. They are RED against the
15
+ // current bus and turn GREEN once generation no longer parks the drain loop.
16
+
17
+ import { describe, it, expect } from "vitest";
18
+ import { PipelineBusImpl, Route } from "./pipeline-bus.js";
19
+ import { VoiceAgentSession } from "./voice-agent-session.js";
20
+ import type { PipelineBus } from "./pipeline-bus.js";
21
+ import type { VoicePlugin, PluginConfig } from "./plugin-contract.js";
22
+ import type { VoicePacket } from "./packets.js";
23
+
24
+ function pkt(kind: string, contextId = "t1"): VoicePacket {
25
+ return { kind, contextId, timestampMs: Date.now() } as unknown as VoicePacket;
26
+ }
27
+
28
+ describe("G10 — bus head-of-line blocking", () => {
29
+ it("dispatches a Critical interrupt while a slow Main handler is still running", async () => {
30
+ const bus = new PipelineBusImpl();
31
+ const drain = bus.start();
32
+
33
+ let releaseSlow!: () => void;
34
+ const slowGate = new Promise<void>((resolve) => {
35
+ releaseSlow = resolve;
36
+ });
37
+ const timeline: Array<{ name: string; atMs: number }> = [];
38
+ const t0 = Date.now();
39
+ const mark = (name: string): void => {
40
+ timeline.push({ name, atMs: Date.now() - t0 });
41
+ };
42
+
43
+ // Slow Main handler — stands in for the bridge's long LLM generation. Registered
44
+ // as a concurrent producer so it does not park the drain loop.
45
+ bus.on("eos.turn_complete", async () => {
46
+ mark("slow-start");
47
+ await slowGate;
48
+ mark("slow-end");
49
+ }, { concurrent: true });
50
+ // Critical interrupt handler — stands in for barge-in handling.
51
+ bus.on("interrupt.detected", () => {
52
+ mark("interrupt-dispatched");
53
+ });
54
+
55
+ bus.push(Route.Main, pkt("eos.turn_complete"));
56
+ await new Promise((r) => setTimeout(r, 20)); // let the slow handler start
57
+ bus.push(Route.Critical, pkt("interrupt.detected"));
58
+ await new Promise((r) => setTimeout(r, 80)); // window in which a healthy bus dispatches Critical
59
+
60
+ const interruptedWhileSlowRunning = timeline.some((e) => e.name === "interrupt-dispatched");
61
+
62
+ releaseSlow();
63
+ await new Promise((r) => setTimeout(r, 20));
64
+ bus.stop();
65
+ await drain;
66
+
67
+ // The interrupt must have been dispatched BEFORE the slow handler finished.
68
+ const interrupt = timeline.find((e) => e.name === "interrupt-dispatched");
69
+ const slowEnd = timeline.find((e) => e.name === "slow-end");
70
+ expect(interrupt, `timeline: ${JSON.stringify(timeline)}`).toBeDefined();
71
+ expect(slowEnd).toBeDefined();
72
+ expect(interruptedWhileSlowRunning).toBe(true);
73
+ expect(interrupt!.atMs).toBeLessThan(slowEnd!.atMs);
74
+ });
75
+ });
76
+
77
+ // Production simulation: a realistic streaming LLM bridge emits tokens over time on
78
+ // `eos.turn_complete`. The session sentence-buffers them into `tts.text`. With the
79
+ // bus parked on the bridge's (awaited) handler, the deltas it pushes are not
80
+ // dispatched until the handler returns — so the FIRST sentence does not reach TTS
81
+ // until generation completes (streaming is defeated). Desired: the first sentence
82
+ // reaches TTS WHILE generation is still in flight.
83
+ class StreamingBridgePlugin implements VoicePlugin {
84
+ generationEndAtMs = -1;
85
+ constructor(private readonly t0Ms: number) {}
86
+ async initialize(bus: PipelineBus, _config: PluginConfig): Promise<void> {
87
+ bus.on("eos.turn_complete", async (pkt) => {
88
+ const contextId = (pkt as { contextId: string }).contextId;
89
+ // Two complete sentences streamed as tokens, 60 ms apart (~360 ms total).
90
+ const tokens = ["Hello", " there.", " How", " are", " you?", " done."];
91
+ for (const token of tokens) {
92
+ await new Promise((r) => setTimeout(r, 60));
93
+ bus.push(Route.Main, { kind: "llm.delta", contextId, timestampMs: Date.now(), text: token } as unknown as VoicePacket);
94
+ }
95
+ bus.push(Route.Main, { kind: "llm.done", contextId, timestampMs: Date.now(), text: tokens.join("") } as unknown as VoicePacket);
96
+ this.generationEndAtMs = Date.now() - this.t0Ms;
97
+ }, { concurrent: true });
98
+ }
99
+ async close(): Promise<void> {}
100
+ }
101
+
102
+ class RecordingTtsPlugin implements VoicePlugin {
103
+ ttsTextAtMs: number[] = [];
104
+ constructor(private readonly t0Ms: number) {}
105
+ async initialize(bus: PipelineBus, _config: PluginConfig): Promise<void> {
106
+ bus.on("tts.text", () => {
107
+ this.ttsTextAtMs.push(Date.now() - this.t0Ms);
108
+ });
109
+ }
110
+ async close(): Promise<void> {}
111
+ }
112
+
113
+ describe("G10 — production simulation: streaming LLM -> TTS not deferred by the bus", () => {
114
+ it("delivers the first sentence to TTS while generation is still in flight", async () => {
115
+ const t0 = Date.now();
116
+ const bridge = new StreamingBridgePlugin(t0);
117
+ const tts = new RecordingTtsPlugin(t0);
118
+ const session = new VoiceAgentSession({ plugins: { bridge: {}, tts: {} } });
119
+ session.registerPlugin("bridge", bridge);
120
+ session.registerPlugin("tts", tts);
121
+ await session.start();
122
+
123
+ session.bus.push(Route.Main, {
124
+ kind: "eos.turn_complete",
125
+ contextId: "turn-1",
126
+ timestampMs: Date.now(),
127
+ text: "what time is it",
128
+ transcripts: [],
129
+ } as unknown as VoicePacket);
130
+
131
+ // Wait past full generation + drain.
132
+ await new Promise((r) => setTimeout(r, 700));
133
+ if (session.state !== "closed") await session.close();
134
+
135
+ const firstTtsTextAtMs = tts.ttsTextAtMs[0] ?? Number.POSITIVE_INFINITY;
136
+ // The first sentence ("Hello there.") completes ~120 ms into a ~360 ms generation.
137
+ // Bug: it is not delivered until generation ends (>= generationEndAtMs).
138
+ // Desired: delivered while generation is still streaming.
139
+ expect(tts.ttsTextAtMs.length, "expected at least one tts.text").toBeGreaterThan(0);
140
+ expect(
141
+ firstTtsTextAtMs,
142
+ `firstTtsText=${firstTtsTextAtMs}ms generationEnd=${bridge.generationEndAtMs}ms all=${JSON.stringify(tts.ttsTextAtMs)}`,
143
+ ).toBeLessThan(bridge.generationEndAtMs);
144
+ });
145
+ });
@@ -0,0 +1,211 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { PipelineBusImpl, Route, type PipelineBusConfig } from "../src/pipeline-bus.js";
5
+ import type { VoicePacket } from "../src/packets.js";
6
+
7
+ // =============================================================================
8
+ // Helpers
9
+ // =============================================================================
10
+
11
+ function pkt(kind: string, contextId = "ctx-1"): VoicePacket {
12
+ return { kind, contextId, timestampMs: Date.now() };
13
+ }
14
+
15
+ function createBus(config?: PipelineBusConfig): PipelineBusImpl {
16
+ return new PipelineBusImpl(config);
17
+ }
18
+
19
+ /** Start bus, run fn, stop bus, await drain completion. */
20
+ async function withBus(
21
+ config: PipelineBusConfig | undefined,
22
+ fn: (bus: PipelineBusImpl) => void | Promise<void>,
23
+ ): Promise<void> {
24
+ const bus = createBus(config);
25
+ const startP = bus.start();
26
+ // Give the start loop a tick to begin
27
+ await new Promise((r) => setTimeout(r, 5));
28
+ await fn(bus);
29
+ // Allow pending dispatches to complete
30
+ await new Promise((r) => setTimeout(r, 20));
31
+ bus.stop();
32
+ await startP;
33
+ }
34
+
35
+ // =============================================================================
36
+ // Tests
37
+ // =============================================================================
38
+
39
+ describe("PipelineBusImpl", () => {
40
+ describe("push and drain order", () => {
41
+ it("drains Critical before Main", async () => {
42
+ const processed: string[] = [];
43
+ await withBus(undefined, (bus) => {
44
+ bus.on("critical.event", () => { processed.push("critical"); });
45
+ bus.on("main.event", () => { processed.push("main"); });
46
+ bus.push(Route.Main, pkt("main.event"));
47
+ bus.push(Route.Critical, pkt("critical.event"));
48
+ });
49
+ expect(processed).toEqual(["critical", "main"]);
50
+ });
51
+
52
+ it("drains Main before Background", async () => {
53
+ const processed: string[] = [];
54
+ await withBus(undefined, (bus) => {
55
+ bus.on("main.event", () => { processed.push("main"); });
56
+ bus.on("bg.event", () => { processed.push("bg"); });
57
+ bus.push(Route.Background, pkt("bg.event"));
58
+ bus.push(Route.Main, pkt("main.event"));
59
+ });
60
+ expect(processed).toEqual(["main", "bg"]);
61
+ });
62
+
63
+ it("batches Critical up to criticalBatchSize before yielding", async () => {
64
+ const processed: string[] = [];
65
+ await withBus({ criticalBatchSize: 3 }, (bus) => {
66
+ bus.on("critical.event", () => { processed.push("c"); });
67
+ for (let i = 0; i < 5; i++) {
68
+ bus.push(Route.Critical, pkt("critical.event"));
69
+ }
70
+ });
71
+ expect(processed.length).toBeGreaterThanOrEqual(5);
72
+ });
73
+ });
74
+
75
+ describe("capacity and overflow", () => {
76
+ it("drops oldest Background on overflow", async () => {
77
+ const dropped: VoicePacket[] = [];
78
+ const metrics: string[] = [];
79
+ await withBus(
80
+ { bgCapacity: 2, onBackgroundDrop: (d: VoicePacket) => { dropped.push(d); } },
81
+ (bus) => {
82
+ bus.on("metric.conversation", (pkt: any) => {
83
+ metrics.push(pkt.name);
84
+ });
85
+ bus.push(Route.Background, pkt("bg.1", "id-1"));
86
+ bus.push(Route.Background, pkt("bg.2", "id-2"));
87
+ bus.push(Route.Background, pkt("bg.3", "id-3"));
88
+ },
89
+ );
90
+ expect(dropped.length).toBeGreaterThanOrEqual(1);
91
+ if (dropped.length > 0) {
92
+ expect(dropped[0]!.contextId).toBe("id-1");
93
+ }
94
+ expect(metrics).toContain("pipeline.bus.background.dropped");
95
+ });
96
+
97
+ it("throws on Main overflow", () => {
98
+ const bus = createBus({ mainCapacity: 1 });
99
+ bus.push(Route.Main, pkt("main.1"));
100
+ expect(() => bus.push(Route.Main, pkt("main.2"))).toThrow("Main queue full");
101
+ });
102
+
103
+ it("Critical never overflows", () => {
104
+ const bus = createBus();
105
+ for (let i = 0; i < 10000; i++) {
106
+ bus.push(Route.Critical, pkt("critical.event"));
107
+ }
108
+ expect(true).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("handler registration", () => {
113
+ it("calls matching handler for packet kind", async () => {
114
+ const fn = vi.fn();
115
+ await withBus(undefined, (bus) => {
116
+ bus.on("test.event", fn);
117
+ bus.push(Route.Main, pkt("test.event"));
118
+ });
119
+ expect(fn).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("does not call handler for different kind", async () => {
123
+ const fn = vi.fn();
124
+ await withBus(undefined, (bus) => {
125
+ bus.on("test.event", fn);
126
+ bus.push(Route.Main, pkt("other.event"));
127
+ });
128
+ expect(fn).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("unsubscribe removes handler", async () => {
132
+ const fn = vi.fn();
133
+ await withBus(undefined, (bus) => {
134
+ const unsub = bus.on("test.event", fn);
135
+ unsub();
136
+ bus.push(Route.Main, pkt("test.event"));
137
+ });
138
+ expect(fn).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("multiple handlers for same kind all fire", async () => {
142
+ const fn1 = vi.fn();
143
+ const fn2 = vi.fn();
144
+ await withBus(undefined, (bus) => {
145
+ bus.on("test.event", fn1);
146
+ bus.on("test.event", fn2);
147
+ bus.push(Route.Main, pkt("test.event"));
148
+ });
149
+ expect(fn1).toHaveBeenCalledTimes(1);
150
+ expect(fn2).toHaveBeenCalledTimes(1);
151
+ });
152
+ });
153
+
154
+ describe("allPackets", () => {
155
+ it("publishes every pushed packet with its route", async () => {
156
+ const bus = createBus();
157
+ const reader = bus.allPackets.getReader();
158
+ bus.push(Route.Main, pkt("main.event", "main-1"));
159
+ bus.push(Route.Critical, pkt("critical.event", "critical-1"));
160
+
161
+ const first = await reader.read();
162
+ const second = await reader.read();
163
+ reader.releaseLock();
164
+
165
+ expect(first.value).toMatchObject({
166
+ route: Route.Main,
167
+ packet: { kind: "main.event", contextId: "main-1" },
168
+ });
169
+ expect(second.value).toMatchObject({
170
+ route: Route.Critical,
171
+ packet: { kind: "critical.event", contextId: "critical-1" },
172
+ });
173
+ });
174
+
175
+ it("does not retain packets when no reader is attached (drop-on-unread)", async () => {
176
+ const bus = createBus();
177
+ // No getReader() → stream unlocked → nothing retained (else a call with no
178
+ // recorder would buffer every audio packet and OOM).
179
+ for (let i = 0; i < 1000; i++) bus.push(Route.Main, pkt("main.event", `n-${i}`));
180
+
181
+ // A reader that attaches later sees only packets pushed AFTER it attaches.
182
+ const reader = bus.allPackets.getReader();
183
+ bus.push(Route.Main, pkt("main.event", "after-reader"));
184
+ const next = await reader.read();
185
+ reader.releaseLock();
186
+ expect(next.value).toMatchObject({ packet: { contextId: "after-reader" } });
187
+ });
188
+ });
189
+
190
+ describe("error handling", () => {
191
+ it("handler error pushes VoiceErrorPacket to Critical", async () => {
192
+ const errorHandler = vi.fn();
193
+ await withBus(undefined, (bus) => {
194
+ bus.on("test.event", () => { throw new Error("boom"); });
195
+ bus.on("pipeline.error", errorHandler);
196
+ bus.push(Route.Main, pkt("test.event"));
197
+ });
198
+ expect(errorHandler).toHaveBeenCalled();
199
+ });
200
+
201
+ it("handler error does not stop other handlers", async () => {
202
+ const fn2 = vi.fn();
203
+ await withBus(undefined, (bus) => {
204
+ bus.on("test.event", () => { throw new Error("boom"); });
205
+ bus.on("test.event", fn2);
206
+ bus.push(Route.Main, pkt("test.event"));
207
+ });
208
+ expect(fn2).toHaveBeenCalledTimes(1);
209
+ });
210
+ });
211
+ });
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import type { InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
5
+ import { DeferInteractionPolicy } from "./defer.js";
6
+
7
+ const OBSERVATIONS: InteractionObservation[] = [
8
+ {
9
+ kind: "vad_speech_started",
10
+ contextId: "user",
11
+ timestampMs: 1000,
12
+ confidence: 0.99,
13
+ interruptedContextId: "assistant-turn",
14
+ },
15
+ {
16
+ kind: "vad_speech_activity",
17
+ contextId: "user",
18
+ timestampMs: 1300,
19
+ },
20
+ {
21
+ kind: "vad_speech_ended",
22
+ contextId: "user",
23
+ timestampMs: 2000,
24
+ hasActiveTts: true,
25
+ },
26
+ {
27
+ kind: "vad_barge_in_audio",
28
+ contextId: "user",
29
+ timestampMs: 1500,
30
+ audio: new Uint8Array([1, 2, 3]),
31
+ },
32
+ {
33
+ kind: "stt_partial",
34
+ contextId: "user",
35
+ timestampMs: 1600,
36
+ text: "hello",
37
+ confidence: 0.9,
38
+ interruptedContextId: "assistant-turn",
39
+ },
40
+ {
41
+ kind: "stt_final",
42
+ contextId: "user",
43
+ timestampMs: 2000,
44
+ text: "hello there",
45
+ confidence: 0.95,
46
+ interruptedContextId: "assistant-turn",
47
+ },
48
+ {
49
+ kind: "audio_frame",
50
+ contextId: "user",
51
+ timestampMs: 1700,
52
+ audio: new Int16Array([1, 2, 3]),
53
+ },
54
+ {
55
+ kind: "playout_tick",
56
+ contextId: "assistant-turn",
57
+ timestampMs: 1800,
58
+ playedOutMs: 500,
59
+ ttsActive: true,
60
+ },
61
+ {
62
+ kind: "delegate_state",
63
+ contextId: "turn-1",
64
+ timestampMs: 1900,
65
+ delegateInFlight: true,
66
+ },
67
+ ];
68
+
69
+ describe("DeferInteractionPolicy", () => {
70
+ it("implements InteractionPolicy", () => {
71
+ const policy: InteractionPolicy = new DeferInteractionPolicy();
72
+ expect(policy).toBeInstanceOf(DeferInteractionPolicy);
73
+ });
74
+
75
+ it("observe returns [] for every observation kind", () => {
76
+ const policy = new DeferInteractionPolicy();
77
+ for (const obs of OBSERVATIONS) {
78
+ expect(policy.observe(obs)).toEqual([]);
79
+ }
80
+ });
81
+
82
+ it("reset is a no-op", () => {
83
+ const policy = new DeferInteractionPolicy();
84
+ expect(() => policy.reset("any-context")).not.toThrow();
85
+ });
86
+ });