@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,87 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { PipelineBusImpl } from "./pipeline-bus.js";
5
+ import { ProviderFallback, type FallbackProvider } from "./provider-fallback.js";
6
+ import type { ConversationMetricPacket } from "./packets.js";
7
+
8
+ function provider(
9
+ id: string,
10
+ send: FallbackProvider<string, string>["send"],
11
+ healthProbe: FallbackProvider<string, string>["healthProbe"] = async () => true,
12
+ ): FallbackProvider<string, string> {
13
+ return { id, send, healthProbe };
14
+ }
15
+
16
+ describe("ProviderFallback", () => {
17
+ it("falls through unavailable providers and emits availability metrics", async () => {
18
+ const bus = new PipelineBusImpl();
19
+ const started = bus.start();
20
+ const metrics: ConversationMetricPacket[] = [];
21
+ bus.on("metric.conversation", (pkt) => {
22
+ metrics.push(pkt as ConversationMetricPacket);
23
+ });
24
+ const first = provider("stt.primary", async () => {
25
+ throw new Error("primary down");
26
+ });
27
+ const second = provider("stt.backup", async (req) => `backup:${req}`);
28
+ const fallback = new ProviderFallback([first, second], {
29
+ bus,
30
+ contextId: "turn-1",
31
+ attemptTimeoutMs: 100,
32
+ recoveryProbeIntervalMs: 1000,
33
+ });
34
+
35
+ await expect(fallback.send("hello")).resolves.toBe("backup:hello");
36
+ await new Promise((resolve) => setTimeout(resolve, 10));
37
+
38
+ expect(metrics).toContainEqual(expect.objectContaining({
39
+ contextId: "turn-1",
40
+ name: "stt.primary.availability_changed",
41
+ value: "unavailable",
42
+ }));
43
+
44
+ fallback.close();
45
+ bus.stop();
46
+ await started;
47
+ });
48
+
49
+ it("runs background recovery probes and returns recovered providers to service", async () => {
50
+ const bus = new PipelineBusImpl();
51
+ const started = bus.start();
52
+ const metrics: ConversationMetricPacket[] = [];
53
+ bus.on("metric.conversation", (pkt) => {
54
+ metrics.push(pkt as ConversationMetricPacket);
55
+ });
56
+ let primaryHealthy = false;
57
+ const first = provider(
58
+ "tts.primary",
59
+ async () => {
60
+ if (!primaryHealthy) throw new Error("primary down");
61
+ return "primary";
62
+ },
63
+ async () => primaryHealthy,
64
+ );
65
+ const second = provider("tts.backup", async () => "backup");
66
+ const fallback = new ProviderFallback([first, second], {
67
+ bus,
68
+ contextId: "turn-2",
69
+ attemptTimeoutMs: 100,
70
+ recoveryProbeIntervalMs: 10,
71
+ });
72
+
73
+ await expect(fallback.send("hello")).resolves.toBe("backup");
74
+ primaryHealthy = true;
75
+ await new Promise((resolve) => setTimeout(resolve, 40));
76
+ await expect(fallback.send("hello")).resolves.toBe("primary");
77
+
78
+ expect(metrics).toContainEqual(expect.objectContaining({
79
+ name: "tts.primary.availability_changed",
80
+ value: "available",
81
+ }));
82
+
83
+ fallback.close();
84
+ bus.stop();
85
+ await started;
86
+ });
87
+ });
@@ -0,0 +1,361 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { PipelineBusImpl } from "./pipeline-bus.js";
6
+ import type { ConversationMetricPacket } from "./packets.js";
7
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
8
+ import { HedgedReasoner } from "./reasoner-hedge.js";
9
+ import type { ScheduledCallback, Scheduler } from "./scheduler.js";
10
+
11
+ class FakeScheduler implements Scheduler {
12
+ private readonly callbacks = new Map<string, ScheduledCallback>();
13
+
14
+ schedule(key: string, _delayMs: number, cb: ScheduledCallback): void {
15
+ this.callbacks.set(key, cb);
16
+ }
17
+
18
+ cancel(key: string): void {
19
+ this.callbacks.delete(key);
20
+ }
21
+
22
+ fire(key: string): void {
23
+ const cb = this.callbacks.get(key);
24
+ if (!cb) return;
25
+ this.callbacks.delete(key);
26
+ void cb();
27
+ }
28
+
29
+ has(key: string): boolean {
30
+ return this.callbacks.has(key);
31
+ }
32
+ }
33
+
34
+ class ControllableReasoner implements Reasoner {
35
+ streamInvoked = false;
36
+ capturedSignal: AbortSignal | undefined;
37
+ private deliver: ((part: ReasoningPart) => void) | null = null;
38
+
39
+ stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
40
+ this.streamInvoked = true;
41
+ this.capturedSignal = turn.signal;
42
+ const queue: ReasoningPart[] = [];
43
+ let wake: (() => void) | null = null;
44
+
45
+ this.deliver = (part: ReasoningPart) => {
46
+ queue.push(part);
47
+ wake?.();
48
+ wake = null;
49
+ };
50
+
51
+ async function* generator(): AsyncGenerator<ReasoningPart> {
52
+ while (!turn.signal.aborted) {
53
+ if (queue.length === 0) {
54
+ await new Promise<void>((resolve) => {
55
+ if (turn.signal.aborted) {
56
+ resolve();
57
+ return;
58
+ }
59
+ wake = resolve;
60
+ turn.signal.addEventListener("abort", () => resolve(), { once: true });
61
+ });
62
+ }
63
+ if (turn.signal.aborted) return;
64
+ const part = queue.shift();
65
+ if (part === undefined) return;
66
+ yield part;
67
+ if (part.type === "error" || part.type === "suspended" || part.type === "finish") return;
68
+ }
69
+ }
70
+
71
+ return generator();
72
+ }
73
+
74
+ emit(part: ReasoningPart): void {
75
+ this.deliver?.(part);
76
+ }
77
+ }
78
+
79
+ function baseTurn(signal = new AbortController().signal): ReasonerTurn {
80
+ return { userText: "hi", messages: [{ role: "user", content: "hi" }], signal };
81
+ }
82
+
83
+ async function collect(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
84
+ const parts: ReasoningPart[] = [];
85
+ for await (const part of reasoner.stream(turn)) {
86
+ parts.push(part);
87
+ }
88
+ return parts;
89
+ }
90
+
91
+ function silentReasoner(hook?: (turn: ReasonerTurn) => void): Reasoner {
92
+ return {
93
+ stream(turn) {
94
+ hook?.(turn);
95
+ return (async function*() {
96
+ await new Promise<void>(() => {});
97
+ })();
98
+ },
99
+ };
100
+ }
101
+
102
+ function scriptedReasoner(
103
+ parts: readonly ReasoningPart[],
104
+ hook?: (turn: ReasonerTurn) => void,
105
+ ): Reasoner {
106
+ return {
107
+ stream(turn) {
108
+ hook?.(turn);
109
+ return (async function*() {
110
+ for (const part of parts) {
111
+ if (turn.signal.aborted) return;
112
+ yield part;
113
+ }
114
+ })();
115
+ },
116
+ };
117
+ }
118
+
119
+ describe("HedgedReasoner", () => {
120
+ it("(a) primary fast — backup never started", async () => {
121
+ const primary = new ControllableReasoner();
122
+ const backup = new ControllableReasoner();
123
+ const scheduler = new FakeScheduler();
124
+ const hedge = new HedgedReasoner({
125
+ primary,
126
+ backup,
127
+ hedgeAfterMs: 50,
128
+ scheduler,
129
+ });
130
+
131
+ const streamPromise = collect(hedge, baseTurn());
132
+ primary.emit({ type: "text-delta", text: "hello" });
133
+ primary.emit({ type: "finish", reason: "stop", text: "hello" });
134
+
135
+ const parts = await streamPromise;
136
+
137
+ expect(backup.streamInvoked).toBe(false);
138
+ expect(scheduler.has("hedge")).toBe(false);
139
+ expect(parts).toEqual([
140
+ { type: "text-delta", text: "hello" },
141
+ { type: "finish", reason: "stop", text: "hello" },
142
+ ]);
143
+ });
144
+
145
+ it("(b) hedge fires, primary still wins", async () => {
146
+ const primary = new ControllableReasoner();
147
+ const backup = new ControllableReasoner();
148
+ const scheduler = new FakeScheduler();
149
+ const bus = new PipelineBusImpl();
150
+ const started = bus.start();
151
+ const metrics: ConversationMetricPacket[] = [];
152
+ bus.on("metric.conversation", (pkt) => {
153
+ metrics.push(pkt as ConversationMetricPacket);
154
+ });
155
+
156
+ const hedge = new HedgedReasoner({
157
+ primary,
158
+ backup,
159
+ hedgeAfterMs: 50,
160
+ scheduler,
161
+ bus,
162
+ contextId: "ctx-1",
163
+ });
164
+
165
+ const streamPromise = collect(hedge, baseTurn());
166
+ await Promise.resolve();
167
+ scheduler.fire("hedge");
168
+ await Promise.resolve();
169
+ expect(backup.streamInvoked).toBe(true);
170
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
171
+
172
+ primary.emit({ type: "text-delta", text: "primary" });
173
+ backup.emit({ type: "text-delta", text: "backup" });
174
+ primary.emit({ type: "finish", reason: "stop", text: "primary" });
175
+
176
+ const parts = await streamPromise;
177
+
178
+ expect(parts).toEqual([
179
+ { type: "text-delta", text: "primary" },
180
+ { type: "finish", reason: "stop", text: "primary" },
181
+ ]);
182
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "primary" }));
183
+ expect(backup.capturedSignal?.aborted).toBe(true);
184
+
185
+ bus.stop();
186
+ await started;
187
+ });
188
+
189
+ it("(c) backup wins — no interleaving", async () => {
190
+ let primarySignal: AbortSignal | undefined;
191
+ let backupStarted = false;
192
+ const scheduler = new FakeScheduler();
193
+ const hedge = new HedgedReasoner({
194
+ primary: silentReasoner((turn) => {
195
+ primarySignal = turn.signal;
196
+ }),
197
+ backup: scriptedReasoner(
198
+ [
199
+ { type: "text-delta", text: "backup" },
200
+ { type: "finish", reason: "stop", text: "backup" },
201
+ ],
202
+ () => {
203
+ backupStarted = true;
204
+ },
205
+ ),
206
+ hedgeAfterMs: 10,
207
+ scheduler,
208
+ });
209
+
210
+ const streamPromise = collect(hedge, baseTurn());
211
+ await Promise.resolve();
212
+ scheduler.fire("hedge");
213
+ const parts = await streamPromise;
214
+
215
+ expect(backupStarted).toBe(true);
216
+ expect(parts).toEqual([
217
+ { type: "text-delta", text: "backup" },
218
+ { type: "finish", reason: "stop", text: "backup" },
219
+ ]);
220
+ expect(primarySignal?.aborted).toBe(true);
221
+ });
222
+
223
+ it("(d) loser aborted after commit", async () => {
224
+ let primarySignal: AbortSignal | undefined;
225
+ let backupSignal: AbortSignal | undefined;
226
+ const scheduler = new FakeScheduler();
227
+ const hedge = new HedgedReasoner({
228
+ primary: silentReasoner((turn) => {
229
+ primarySignal = turn.signal;
230
+ }),
231
+ backup: scriptedReasoner(
232
+ [
233
+ { type: "text-delta", text: "backup" },
234
+ { type: "finish", reason: "stop", text: "backup" },
235
+ ],
236
+ (turn) => {
237
+ backupSignal = turn.signal;
238
+ },
239
+ ),
240
+ hedgeAfterMs: 5,
241
+ scheduler,
242
+ });
243
+
244
+ const streamPromise = collect(hedge, baseTurn());
245
+ await Promise.resolve();
246
+ scheduler.fire("hedge");
247
+ await streamPromise;
248
+
249
+ expect(primarySignal?.aborted).toBe(true);
250
+ expect(backupSignal?.aborted).toBe(false);
251
+ });
252
+
253
+ it("(e) pre-commit primary error fails over to backup", async () => {
254
+ let backupStarted = false;
255
+ const hedge = new HedgedReasoner({
256
+ primary: scriptedReasoner([
257
+ {
258
+ type: "error",
259
+ cause: new Error("primary down"),
260
+ recoverable: true,
261
+ },
262
+ ]),
263
+ backup: scriptedReasoner(
264
+ [
265
+ { type: "text-delta", text: "recovered" },
266
+ { type: "finish", reason: "stop", text: "recovered" },
267
+ ],
268
+ () => {
269
+ backupStarted = true;
270
+ },
271
+ ),
272
+ hedgeAfterMs: 100,
273
+ scheduler: new FakeScheduler(),
274
+ });
275
+
276
+ const parts = await collect(hedge, baseTurn());
277
+
278
+ expect(backupStarted).toBe(true);
279
+ expect(parts).toEqual([
280
+ { type: "text-delta", text: "recovered" },
281
+ { type: "finish", reason: "stop", text: "recovered" },
282
+ ]);
283
+ expect(parts.some((p) => p.type === "error")).toBe(false);
284
+ });
285
+
286
+ it("(f) post-commit error forwarded verbatim", async () => {
287
+ const primary = new ControllableReasoner();
288
+ const backup = new ControllableReasoner();
289
+ const hedge = new HedgedReasoner({
290
+ primary,
291
+ backup,
292
+ hedgeAfterMs: 100,
293
+ scheduler: new FakeScheduler(),
294
+ });
295
+
296
+ const streamPromise = collect(hedge, baseTurn());
297
+ primary.emit({ type: "text-delta", text: "partial" });
298
+ const err: ReasoningPart = {
299
+ type: "error",
300
+ cause: new Error("mid-stream"),
301
+ recoverable: false,
302
+ };
303
+ primary.emit(err);
304
+ backup.emit({ type: "text-delta", text: "backup-late" });
305
+
306
+ const parts = await streamPromise;
307
+
308
+ expect(parts).toEqual([
309
+ { type: "text-delta", text: "partial" },
310
+ err,
311
+ ]);
312
+ expect(backup.streamInvoked).toBe(false);
313
+ });
314
+
315
+ it("(g) metrics — fired iff backup started, committed_to reflects winner; no bus is safe", async () => {
316
+ const scheduler = new FakeScheduler();
317
+ const bus = new PipelineBusImpl();
318
+ const started = bus.start();
319
+ const metrics: ConversationMetricPacket[] = [];
320
+ bus.on("metric.conversation", (pkt) => {
321
+ metrics.push(pkt as ConversationMetricPacket);
322
+ });
323
+
324
+ const hedge = new HedgedReasoner({
325
+ primary: silentReasoner(),
326
+ backup: scriptedReasoner([
327
+ { type: "text-delta", text: "b" },
328
+ { type: "finish", reason: "stop", text: "b" },
329
+ ]),
330
+ hedgeAfterMs: 10,
331
+ scheduler,
332
+ bus,
333
+ contextId: "ctx-m",
334
+ });
335
+
336
+ const streamPromise = collect(hedge, baseTurn());
337
+ await Promise.resolve();
338
+ scheduler.fire("hedge");
339
+ await streamPromise;
340
+
341
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
342
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "backup" }));
343
+
344
+ const noBusPrimary = new ControllableReasoner();
345
+ const noBusBackup = new ControllableReasoner();
346
+ const noBusHedge = new HedgedReasoner({
347
+ primary: noBusPrimary,
348
+ backup: noBusBackup,
349
+ hedgeAfterMs: 10,
350
+ scheduler: new FakeScheduler(),
351
+ });
352
+
353
+ const noBusPromise = collect(noBusHedge, baseTurn());
354
+ noBusPrimary.emit({ type: "text-delta", text: "ok" });
355
+ noBusPrimary.emit({ type: "finish", reason: "stop", text: "ok" });
356
+ await expect(noBusPromise).resolves.toHaveLength(2);
357
+
358
+ bus.stop();
359
+ await started;
360
+ });
361
+ });
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { PipelineBusImpl } from "./pipeline-bus.js";
6
+ import type { ConversationMetricPacket } from "./packets.js";
7
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
8
+ import { RoutingReasoner } from "./reasoner-route.js";
9
+
10
+ class ControllableReasoner implements Reasoner {
11
+ streamInvoked = false;
12
+ streamCallCount = 0;
13
+ capturedSignal: AbortSignal | undefined;
14
+ private deliver: ((part: ReasoningPart) => void) | null = null;
15
+
16
+ stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
17
+ this.streamInvoked = true;
18
+ this.streamCallCount += 1;
19
+ this.capturedSignal = turn.signal;
20
+ const queue: ReasoningPart[] = [];
21
+ let wake: (() => void) | null = null;
22
+
23
+ this.deliver = (part: ReasoningPart) => {
24
+ queue.push(part);
25
+ wake?.();
26
+ wake = null;
27
+ };
28
+
29
+ async function* generator(): AsyncGenerator<ReasoningPart> {
30
+ while (!turn.signal.aborted) {
31
+ if (queue.length === 0) {
32
+ await new Promise<void>((resolve) => {
33
+ if (turn.signal.aborted) {
34
+ resolve();
35
+ return;
36
+ }
37
+ wake = resolve;
38
+ turn.signal.addEventListener("abort", () => resolve(), { once: true });
39
+ });
40
+ }
41
+ if (turn.signal.aborted) return;
42
+ const part = queue.shift();
43
+ if (part === undefined) return;
44
+ yield part;
45
+ if (part.type === "error" || part.type === "suspended" || part.type === "finish") return;
46
+ }
47
+ }
48
+
49
+ return generator();
50
+ }
51
+
52
+ emit(part: ReasoningPart): void {
53
+ this.deliver?.(part);
54
+ }
55
+ }
56
+
57
+ function baseTurn(signal = new AbortController().signal): ReasonerTurn {
58
+ return { userText: "hi", messages: [{ role: "user", content: "hi" }], signal };
59
+ }
60
+
61
+ async function collect(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
62
+ const parts: ReasoningPart[] = [];
63
+ for await (const part of reasoner.stream(turn)) {
64
+ parts.push(part);
65
+ }
66
+ return parts;
67
+ }
68
+
69
+ function scriptedReasoner(
70
+ parts: readonly ReasoningPart[],
71
+ hook?: (turn: ReasonerTurn) => void,
72
+ ): Reasoner {
73
+ return {
74
+ stream(turn) {
75
+ hook?.(turn);
76
+ return (async function*() {
77
+ for (const part of parts) {
78
+ if (turn.signal.aborted) return;
79
+ yield part;
80
+ }
81
+ })();
82
+ },
83
+ };
84
+ }
85
+
86
+ const fastParts: ReasoningPart[] = [
87
+ { type: "text-delta", text: "fast" },
88
+ { type: "finish", reason: "stop", text: "fast" },
89
+ ];
90
+
91
+ const deepParts: ReasoningPart[] = [
92
+ { type: "text-delta", text: "deep" },
93
+ { type: "finish", reason: "stop", text: "deep" },
94
+ ];
95
+
96
+ describe("RoutingReasoner", () => {
97
+ it("classify routing — fast and deep without speculation", async () => {
98
+ let fastInvoked = false;
99
+ let deepInvoked = false;
100
+
101
+ const routes = [
102
+ {
103
+ id: "fast",
104
+ reasoner: scriptedReasoner(fastParts, () => {
105
+ fastInvoked = true;
106
+ }),
107
+ },
108
+ {
109
+ id: "deep",
110
+ reasoner: scriptedReasoner(deepParts, () => {
111
+ deepInvoked = true;
112
+ }),
113
+ },
114
+ ];
115
+
116
+ const fastRouter = new RoutingReasoner({
117
+ routes,
118
+ classify: () => "fast",
119
+ });
120
+ const fastOutput = await collect(fastRouter, baseTurn());
121
+ expect(fastOutput).toEqual(fastParts);
122
+ expect(fastInvoked).toBe(true);
123
+ expect(deepInvoked).toBe(false);
124
+
125
+ fastInvoked = false;
126
+ deepInvoked = false;
127
+
128
+ const deepRouter = new RoutingReasoner({
129
+ routes,
130
+ classify: () => "deep",
131
+ });
132
+ const deepOutput = await collect(deepRouter, baseTurn());
133
+ expect(deepOutput).toEqual(deepParts);
134
+ expect(deepInvoked).toBe(true);
135
+ expect(fastInvoked).toBe(false);
136
+ });
137
+
138
+ it("speculation kept (agree)", async () => {
139
+ const fast = new ControllableReasoner();
140
+ const deep = new ControllableReasoner();
141
+ const bus = new PipelineBusImpl();
142
+ const started = bus.start();
143
+ const metrics: ConversationMetricPacket[] = [];
144
+ bus.on("metric.conversation", (pkt) => {
145
+ metrics.push(pkt as ConversationMetricPacket);
146
+ });
147
+
148
+ const router = new RoutingReasoner({
149
+ routes: [
150
+ { id: "fast", reasoner: fast },
151
+ { id: "deep", reasoner: deep },
152
+ ],
153
+ classify: () => "fast",
154
+ speculateRouteId: "fast",
155
+ bus,
156
+ contextId: "ctx-1",
157
+ });
158
+
159
+ const streamPromise = collect(router, baseTurn());
160
+ fast.emit({ type: "text-delta", text: "fast" });
161
+ fast.emit({ type: "finish", reason: "stop", text: "fast" });
162
+ const parts = await streamPromise;
163
+
164
+ expect(fast.streamCallCount).toBe(1);
165
+ expect(deep.streamInvoked).toBe(false);
166
+ expect(parts).toEqual(fastParts);
167
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "route.selected", value: "fast" }));
168
+ expect(metrics.some((m) => m.name === "route.mispredict")).toBe(false);
169
+
170
+ bus.stop();
171
+ await started;
172
+ });
173
+
174
+ it("speculation discarded (disagree) — no forwarded part from mispredicted route", async () => {
175
+ const fast = new ControllableReasoner();
176
+ let deepInvoked = false;
177
+ let resolveClassify: ((id: string) => void) | undefined;
178
+ const classifyGate = new Promise<string>((resolve) => {
179
+ resolveClassify = resolve;
180
+ });
181
+
182
+ const bus = new PipelineBusImpl();
183
+ const started = bus.start();
184
+ const metrics: ConversationMetricPacket[] = [];
185
+ bus.on("metric.conversation", (pkt) => {
186
+ metrics.push(pkt as ConversationMetricPacket);
187
+ });
188
+
189
+ const router = new RoutingReasoner({
190
+ routes: [
191
+ { id: "fast", reasoner: fast },
192
+ {
193
+ id: "deep",
194
+ reasoner: scriptedReasoner(deepParts, () => {
195
+ deepInvoked = true;
196
+ }),
197
+ },
198
+ ],
199
+ classify: () => classifyGate,
200
+ speculateRouteId: "fast",
201
+ bus,
202
+ contextId: "ctx-2",
203
+ });
204
+
205
+ const streamPromise = collect(router, baseTurn());
206
+ await Promise.resolve();
207
+ fast.emit({ type: "text-delta", text: "must-not-forward" });
208
+ resolveClassify!("deep");
209
+ const parts = await streamPromise;
210
+
211
+ expect(fast.capturedSignal?.aborted).toBe(true);
212
+ expect(fast.streamCallCount).toBe(1);
213
+ expect(deepInvoked).toBe(true);
214
+ expect(parts).toEqual(deepParts);
215
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "route.mispredict", value: "1" }));
216
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "route.selected", value: "deep" }));
217
+
218
+ bus.stop();
219
+ await started;
220
+ });
221
+
222
+ it("R8 passthrough — single route equals direct stream", async () => {
223
+ const soleParts: ReasoningPart[] = [
224
+ { type: "text-delta", text: "only" },
225
+ { type: "finish", reason: "stop", text: "only" },
226
+ ];
227
+ const sole = scriptedReasoner(soleParts);
228
+ const direct = await collect(sole, baseTurn());
229
+
230
+ const router = new RoutingReasoner({
231
+ routes: [{ id: "only", reasoner: sole }],
232
+ classify: () => "only",
233
+ });
234
+ const routed = await collect(router, baseTurn());
235
+
236
+ expect(routed).toEqual(direct);
237
+ expect(routed).toEqual(soleParts);
238
+ });
239
+
240
+ it("unknown id — throws clear error", async () => {
241
+ const router = new RoutingReasoner({
242
+ routes: [{ id: "fast", reasoner: scriptedReasoner(fastParts) }],
243
+ classify: () => "missing",
244
+ });
245
+
246
+ await expect(collect(router, baseTurn())).rejects.toThrow('RoutingReasoner: unknown route id "missing"');
247
+ });
248
+ });