@kuralle-syrinx/core 4.1.0 → 4.3.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.
- package/README.md +4 -0
- package/package.json +22 -3
- package/src/audio/alaw.ts +56 -0
- package/src/audio/g722.ts +329 -0
- package/src/audio/index.ts +12 -0
- package/src/audio/loudness.ts +87 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/idle-timeout.ts +1 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +101 -1
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +116 -0
- package/src/iu-ledger.ts +110 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +137 -2
- package/src/packets.ts +140 -4
- package/src/plugin-contract.ts +41 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.ts +155 -0
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.ts +113 -0
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +45 -4
- package/src/voice-agent-session.ts +682 -53
- package/src/voice-text.ts +69 -0
- package/src/audio/audio.test.ts +0 -285
- package/src/audio-envelope.test.ts +0 -167
- package/src/error-handler.test.ts +0 -56
- package/src/latency-filler.test.ts +0 -62
- package/src/observability-observer.test.ts +0 -245
- package/src/observability.test.ts +0 -85
- package/src/packet-factories.test.ts +0 -34
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- package/src/tts-playout-clock.test.ts +0 -125
- package/src/turn-arbiter.characterization.test.ts +0 -477
- package/src/turn-arbiter.test.ts +0 -567
- package/src/voice-agent-session.test.ts +0 -2879
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
package/src/pipeline-bus.test.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import {
|
|
5
|
-
PrimarySpeakerGate,
|
|
6
|
-
extractSpeakerFingerprint,
|
|
7
|
-
fingerprintSimilarity,
|
|
8
|
-
} from "./primary-speaker-gate.js";
|
|
9
|
-
import * as primarySpeakerGateModule from "./primary-speaker-gate.js";
|
|
10
|
-
import {
|
|
11
|
-
ASSISTANT_ECHO_TONE_HZ,
|
|
12
|
-
BYSTANDER_SPEAKER_TONE_HZ,
|
|
13
|
-
PRIMARY_SPEAKER_TONE_HZ,
|
|
14
|
-
mixPcm16,
|
|
15
|
-
synthesizeTonePcm16,
|
|
16
|
-
} from "./primary-speaker-fixtures.js";
|
|
17
|
-
|
|
18
|
-
describe("extractSpeakerFingerprint", () => {
|
|
19
|
-
it("separates distinct synthetic speakers by band shape", () => {
|
|
20
|
-
const primary = extractSpeakerFingerprint(
|
|
21
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 40 }),
|
|
22
|
-
);
|
|
23
|
-
const bystander = extractSpeakerFingerprint(
|
|
24
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 40 }),
|
|
25
|
-
);
|
|
26
|
-
expect(primary).not.toBeNull();
|
|
27
|
-
expect(bystander).not.toBeNull();
|
|
28
|
-
const self = fingerprintSimilarity(primary!, primary!);
|
|
29
|
-
const cross = fingerprintSimilarity(primary!, bystander!);
|
|
30
|
-
expect(self).toBeGreaterThan(0.95);
|
|
31
|
-
expect(cross).toBeLessThan(0.72);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("PrimarySpeakerGate", () => {
|
|
36
|
-
it("commits barge-in for primary-only sustained speech", () => {
|
|
37
|
-
const gate = new PrimarySpeakerGate();
|
|
38
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
39
|
-
gate.enrollUserTurnChunk(primary);
|
|
40
|
-
gate.lockProfileFromFirstTurn();
|
|
41
|
-
gate.beginBargeInWindow();
|
|
42
|
-
for (let i = 0; i < 6; i += 1) {
|
|
43
|
-
gate.observeBargeInChunk(primary);
|
|
44
|
-
}
|
|
45
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("suppresses bystander sustained speech", () => {
|
|
49
|
-
const gate = new PrimarySpeakerGate();
|
|
50
|
-
gate.enrollUserTurnChunk(
|
|
51
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
52
|
-
);
|
|
53
|
-
gate.lockProfileFromFirstTurn();
|
|
54
|
-
gate.beginBargeInWindow();
|
|
55
|
-
const bystander = synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
56
|
-
for (let i = 0; i < 8; i += 1) {
|
|
57
|
-
gate.observeBargeInChunk(bystander);
|
|
58
|
-
}
|
|
59
|
-
expect(gate.shouldCommitBargeIn()).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("suppresses assistant echo over primary profile", () => {
|
|
63
|
-
const gate = new PrimarySpeakerGate();
|
|
64
|
-
gate.enrollUserTurnChunk(
|
|
65
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
66
|
-
);
|
|
67
|
-
gate.lockProfileFromFirstTurn();
|
|
68
|
-
const echo = synthesizeTonePcm16({
|
|
69
|
-
frequencyHz: ASSISTANT_ECHO_TONE_HZ,
|
|
70
|
-
durationMs: 32,
|
|
71
|
-
amplitude: 0.2,
|
|
72
|
-
});
|
|
73
|
-
gate.observeAssistantPlayout(
|
|
74
|
-
synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
|
|
75
|
-
);
|
|
76
|
-
gate.beginBargeInWindow();
|
|
77
|
-
for (let i = 0; i < 8; i += 1) {
|
|
78
|
-
gate.observeBargeInChunk(echo);
|
|
79
|
-
}
|
|
80
|
-
expect(gate.shouldCommitBargeIn()).toBe(false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("falls back to permissive commit when no profile is locked", () => {
|
|
84
|
-
const gate = new PrimarySpeakerGate();
|
|
85
|
-
gate.beginBargeInWindow();
|
|
86
|
-
gate.observeBargeInChunk(
|
|
87
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
88
|
-
);
|
|
89
|
-
expect(gate.hasProfile()).toBe(false);
|
|
90
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("commits barge-in when primary similarity beats echo even above threshold", () => {
|
|
94
|
-
const gate = new PrimarySpeakerGate({ similarityThreshold: 0.72, echoDominanceMargin: 0.12 });
|
|
95
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
96
|
-
gate.enrollUserTurnChunk(primary);
|
|
97
|
-
gate.lockProfileFromFirstTurn();
|
|
98
|
-
gate.observeAssistantPlayout(
|
|
99
|
-
synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
const profile = (gate as unknown as { profile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>> }).profile;
|
|
103
|
-
const assistantProfile = (gate as unknown as {
|
|
104
|
-
assistantProfile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>>;
|
|
105
|
-
}).assistantProfile;
|
|
106
|
-
|
|
107
|
-
vi.spyOn(primarySpeakerGateModule, "fingerprintSimilarity").mockImplementation((_frame, reference) => {
|
|
108
|
-
if (reference === profile) return 0.95;
|
|
109
|
-
if (reference === assistantProfile) return 0.73;
|
|
110
|
-
return 0;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
gate.beginBargeInWindow();
|
|
114
|
-
for (let i = 0; i < 6; i += 1) {
|
|
115
|
-
gate.observeBargeInChunk(primary);
|
|
116
|
-
}
|
|
117
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
118
|
-
vi.restoreAllMocks();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("allows mixed audio when primary dominates", () => {
|
|
122
|
-
const gate = new PrimarySpeakerGate();
|
|
123
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
124
|
-
gate.enrollUserTurnChunk(primary);
|
|
125
|
-
gate.lockProfileFromFirstTurn();
|
|
126
|
-
const mixed = mixPcm16(
|
|
127
|
-
[
|
|
128
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
129
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32, amplitude: 0.08 }),
|
|
130
|
-
],
|
|
131
|
-
[1, 1],
|
|
132
|
-
);
|
|
133
|
-
gate.beginBargeInWindow();
|
|
134
|
-
for (let i = 0; i < 6; i += 1) {
|
|
135
|
-
gate.observeBargeInChunk(mixed);
|
|
136
|
-
}
|
|
137
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("mixes PCM chunks whose byteOffset is odd", () => {
|
|
141
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
142
|
-
const backing = new Uint8Array(primary.byteLength + 1);
|
|
143
|
-
backing.set(primary, 1);
|
|
144
|
-
const oddOffsetPrimary = backing.subarray(1);
|
|
145
|
-
expect(oddOffsetPrimary.byteOffset % 2).toBe(1);
|
|
146
|
-
|
|
147
|
-
expect(() => mixPcm16([oddOffsetPrimary], [1])).not.toThrow();
|
|
148
|
-
expect(mixPcm16([oddOffsetPrimary], [1])).toEqual(primary);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
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
|
-
});
|
package/src/reasoner.test.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
//
|
|
3
|
-
// Compile-guard: pins the ReasoningPart union shape without runtime consumers.
|
|
4
|
-
|
|
5
|
-
import { describe, expect, it } from "vitest";
|
|
6
|
-
|
|
7
|
-
import type { Reasoner, ReasonerMessage, ReasonerTurn, ReasoningPart } from "./reasoner.js";
|
|
8
|
-
|
|
9
|
-
describe("Reasoner seam types", () => {
|
|
10
|
-
it("reasoner_union_compile_guard", async () => {
|
|
11
|
-
const userMsg = { role: "user", content: "hi" } satisfies ReasonerMessage;
|
|
12
|
-
|
|
13
|
-
const turn: ReasonerTurn = {
|
|
14
|
-
userText: "hi",
|
|
15
|
-
messages: [userMsg],
|
|
16
|
-
signal: new AbortController().signal,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const textDeltaPart = { type: "text-delta", text: "hello" } satisfies ReasoningPart;
|
|
20
|
-
const toolCallPart = {
|
|
21
|
-
type: "tool-call",
|
|
22
|
-
toolId: "tool-1",
|
|
23
|
-
toolName: "doThing",
|
|
24
|
-
args: { input: 123 },
|
|
25
|
-
} satisfies ReasoningPart;
|
|
26
|
-
const toolResultPart = {
|
|
27
|
-
type: "tool-result",
|
|
28
|
-
toolId: "tool-1",
|
|
29
|
-
toolName: "doThing",
|
|
30
|
-
result: "ok",
|
|
31
|
-
} satisfies ReasoningPart;
|
|
32
|
-
const suspendedPart = {
|
|
33
|
-
type: "suspended",
|
|
34
|
-
runId: "run-1",
|
|
35
|
-
toolId: "tool-1",
|
|
36
|
-
prompt: "Pause for human-in-the-loop.",
|
|
37
|
-
payload: { step: 3 },
|
|
38
|
-
} satisfies ReasoningPart;
|
|
39
|
-
const errorPart = {
|
|
40
|
-
type: "error",
|
|
41
|
-
cause: new Error("backend exploded"),
|
|
42
|
-
recoverable: true,
|
|
43
|
-
} satisfies ReasoningPart;
|
|
44
|
-
const finishPart = { type: "finish", reason: "stop", text: "done" } satisfies ReasoningPart;
|
|
45
|
-
|
|
46
|
-
const emptyStream = async function*(): AsyncIterable<ReasoningPart> {
|
|
47
|
-
// Intentionally empty: this is purely a type-level compile guard.
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const reasoner: Reasoner = {
|
|
51
|
-
stream: (_turn: ReasonerTurn): AsyncIterable<ReasoningPart> => emptyStream(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const collected: ReasoningPart[] = [];
|
|
55
|
-
for await (const part of reasoner.stream(turn)) {
|
|
56
|
-
collected.push(part);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
expect(collected).toEqual([]);
|
|
60
|
-
expect(textDeltaPart).toBeDefined();
|
|
61
|
-
expect(toolCallPart).toBeDefined();
|
|
62
|
-
expect(toolResultPart).toBeDefined();
|
|
63
|
-
expect(suspendedPart).toBeDefined();
|
|
64
|
-
expect(errorPart).toBeDefined();
|
|
65
|
-
expect(finishPart).toBeDefined();
|
|
66
|
-
expect(reasoner).toBeDefined();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
package/src/retry.test.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { DEFAULT_RETRY_CONFIG, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG, VOICE_PROVIDER_RETRY_CONFIG, readProviderRetryConfig, readRetryConfig, retryDelayMs, retryDelayWithJitterMs, waitForRetryDelay } from "./retry.js";
|
|
6
|
-
|
|
7
|
-
describe("retry helpers", () => {
|
|
8
|
-
it("reads bounded retry config from plugin config", () => {
|
|
9
|
-
expect(
|
|
10
|
-
readRetryConfig({
|
|
11
|
-
retry_max_attempts: 5,
|
|
12
|
-
retry_base_delay_ms: 100,
|
|
13
|
-
retry_max_delay_ms: 900,
|
|
14
|
-
}),
|
|
15
|
-
).toEqual({
|
|
16
|
-
maxAttempts: 5,
|
|
17
|
-
baseDelayMs: 100,
|
|
18
|
-
maxDelayMs: 900,
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("backs off exponentially up to the configured cap", () => {
|
|
23
|
-
const config = {
|
|
24
|
-
maxAttempts: 5,
|
|
25
|
-
baseDelayMs: 100,
|
|
26
|
-
maxDelayMs: 250,
|
|
27
|
-
};
|
|
28
|
-
expect(retryDelayMs(1, config)).toBe(100);
|
|
29
|
-
expect(retryDelayMs(2, config)).toBe(200);
|
|
30
|
-
expect(retryDelayMs(3, config)).toBe(250);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("applies equal jitter: half the deterministic delay plus a random half", () => {
|
|
34
|
-
const config = { maxAttempts: 5, baseDelayMs: 100, maxDelayMs: 1000 };
|
|
35
|
-
// attempt 2 deterministic = 200 → equal-jitter range [100, 200].
|
|
36
|
-
expect(retryDelayWithJitterMs(2, config, () => 0)).toBe(100);
|
|
37
|
-
expect(retryDelayWithJitterMs(2, config, () => 1)).toBe(200);
|
|
38
|
-
expect(retryDelayWithJitterMs(2, config, () => 0.5)).toBe(150);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("can be aborted while waiting", async () => {
|
|
42
|
-
vi.useFakeTimers();
|
|
43
|
-
const controller = new AbortController();
|
|
44
|
-
const wait = waitForRetryDelay(
|
|
45
|
-
1,
|
|
46
|
-
{
|
|
47
|
-
maxAttempts: 3,
|
|
48
|
-
baseDelayMs: 1000,
|
|
49
|
-
maxDelayMs: 1000,
|
|
50
|
-
},
|
|
51
|
-
controller.signal,
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
controller.abort();
|
|
55
|
-
await expect(wait).rejects.toMatchObject({ name: "AbortError" });
|
|
56
|
-
vi.useRealTimers();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("voice-provider profile keeps fast first reconnect but raises the backoff cap", () => {
|
|
60
|
-
// Fast first reconnect (no multi-second floor → no dead air on transient blips)...
|
|
61
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(250);
|
|
62
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(DEFAULT_RETRY_CONFIG.baseDelayMs);
|
|
63
|
-
// ...but a patient cap for persistent provider failure (vs the 2s default).
|
|
64
|
-
expect(retryDelayMs(10, VOICE_PROVIDER_RETRY_CONFIG)).toBe(10_000);
|
|
65
|
-
expect(VOICE_PROVIDER_RETRY_CONFIG.maxAttempts).toBeGreaterThan(DEFAULT_RETRY_CONFIG.maxAttempts);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("readProviderRetryConfig defaults to the voice-provider profile but honors overrides", () => {
|
|
69
|
-
expect(readProviderRetryConfig({})).toEqual(VOICE_PROVIDER_RETRY_CONFIG);
|
|
70
|
-
expect(readProviderRetryConfig({ retry_max_delay_ms: 3000 }).maxDelayMs).toBe(3000);
|
|
71
|
-
// The plain default profile is unchanged (intra-turn low-latency retries).
|
|
72
|
-
expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("offers a provider-outage profile with a 4s floor and 10s cap", () => {
|
|
76
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(4_000);
|
|
77
|
-
expect(retryDelayMs(2, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(8_000);
|
|
78
|
-
expect(retryDelayMs(3, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(10_000);
|
|
79
|
-
expect(readProviderRetryConfig({ retry_profile: "provider_outage" })).toEqual(VOICE_PROVIDER_OUTAGE_RETRY_CONFIG);
|
|
80
|
-
expect(readProviderRetryConfig({ retry_profile: "provider_outage", retry_base_delay_ms: 5000 }).baseDelayMs).toBe(5000);
|
|
81
|
-
expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
|
|
82
|
-
});
|
|
83
|
-
});
|