@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
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { PipelineBusImpl, Route } from "./pipeline-bus.js";
|
|
5
|
-
import { InMemoryMetricsExporter } from "./observability.js";
|
|
6
|
-
import { ObservabilityObserver } from "./observability-observer.js";
|
|
7
|
-
import type {
|
|
8
|
-
TurnBoundaryEventPacket,
|
|
9
|
-
VadSpeechStartedPacket,
|
|
10
|
-
VadSpeechEndedPacket,
|
|
11
|
-
EndOfSpeechPacket,
|
|
12
|
-
TextToSpeechAudioPacket,
|
|
13
|
-
TextToSpeechEndPacket,
|
|
14
|
-
InterruptionDetectedPacket,
|
|
15
|
-
} from "./packets.js";
|
|
16
|
-
|
|
17
|
-
async function drainBus(): Promise<void> {
|
|
18
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function withObserver(
|
|
22
|
-
fn: (ctx: {
|
|
23
|
-
bus: PipelineBusImpl;
|
|
24
|
-
exporter: InMemoryMetricsExporter;
|
|
25
|
-
boundaries: TurnBoundaryEventPacket[];
|
|
26
|
-
}) => void | Promise<void>,
|
|
27
|
-
): Promise<void> {
|
|
28
|
-
const bus = new PipelineBusImpl();
|
|
29
|
-
const exporter = new InMemoryMetricsExporter();
|
|
30
|
-
const boundaries: TurnBoundaryEventPacket[] = [];
|
|
31
|
-
bus.on("obs.turn_boundary", (pkt) => {
|
|
32
|
-
boundaries.push(pkt as TurnBoundaryEventPacket);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const observer = new ObservabilityObserver({
|
|
36
|
-
bus,
|
|
37
|
-
exporter,
|
|
38
|
-
sessionId: "sess-1",
|
|
39
|
-
dims: { provider: "p1", model: "m1", region: "r1" },
|
|
40
|
-
getContextId: () => "",
|
|
41
|
-
});
|
|
42
|
-
observer.wire();
|
|
43
|
-
|
|
44
|
-
const startP = bus.start();
|
|
45
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
46
|
-
await fn({ bus, exporter, boundaries });
|
|
47
|
-
await drainBus();
|
|
48
|
-
bus.stop();
|
|
49
|
-
await startP;
|
|
50
|
-
observer.dispose();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const SPEECH_ID = "turn-abc";
|
|
54
|
-
|
|
55
|
-
describe("ObservabilityObserver", () => {
|
|
56
|
-
it("emits turn boundaries and v2v_ms histogram for a full turn", async () => {
|
|
57
|
-
await withObserver(async ({ bus, exporter, boundaries }) => {
|
|
58
|
-
bus.push(Route.Main, {
|
|
59
|
-
kind: "vad.speech_started",
|
|
60
|
-
contextId: SPEECH_ID,
|
|
61
|
-
timestampMs: 1000,
|
|
62
|
-
confidence: 0.9,
|
|
63
|
-
} satisfies VadSpeechStartedPacket);
|
|
64
|
-
|
|
65
|
-
bus.push(Route.Main, {
|
|
66
|
-
kind: "vad.speech_ended",
|
|
67
|
-
contextId: SPEECH_ID,
|
|
68
|
-
timestampMs: 1100,
|
|
69
|
-
} satisfies VadSpeechEndedPacket);
|
|
70
|
-
|
|
71
|
-
bus.push(Route.Main, {
|
|
72
|
-
kind: "eos.turn_complete",
|
|
73
|
-
contextId: SPEECH_ID,
|
|
74
|
-
timestampMs: 1200,
|
|
75
|
-
text: "hello",
|
|
76
|
-
transcripts: [],
|
|
77
|
-
} satisfies EndOfSpeechPacket);
|
|
78
|
-
|
|
79
|
-
bus.push(Route.Main, {
|
|
80
|
-
kind: "tts.audio",
|
|
81
|
-
contextId: SPEECH_ID,
|
|
82
|
-
timestampMs: 1300,
|
|
83
|
-
audio: new Uint8Array(320),
|
|
84
|
-
sampleRateHz: 16000,
|
|
85
|
-
} satisfies TextToSpeechAudioPacket);
|
|
86
|
-
|
|
87
|
-
bus.push(Route.Main, {
|
|
88
|
-
kind: "tts.audio",
|
|
89
|
-
contextId: SPEECH_ID,
|
|
90
|
-
timestampMs: 1400,
|
|
91
|
-
audio: new Uint8Array(320),
|
|
92
|
-
sampleRateHz: 16000,
|
|
93
|
-
} satisfies TextToSpeechAudioPacket);
|
|
94
|
-
|
|
95
|
-
bus.push(Route.Main, {
|
|
96
|
-
kind: "tts.end",
|
|
97
|
-
contextId: SPEECH_ID,
|
|
98
|
-
timestampMs: 1500,
|
|
99
|
-
} satisfies TextToSpeechEndPacket);
|
|
100
|
-
await drainBus();
|
|
101
|
-
|
|
102
|
-
const kinds = boundaries.map((b) => b.boundary);
|
|
103
|
-
expect(kinds).toEqual([
|
|
104
|
-
"user_started_speaking",
|
|
105
|
-
"user_stopped_speaking",
|
|
106
|
-
"agent_thinking",
|
|
107
|
-
"agent_started_speaking",
|
|
108
|
-
"agent_audio_done",
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
for (const b of boundaries) {
|
|
112
|
-
expect(b.kind).toBe("obs.turn_boundary");
|
|
113
|
-
expect(b.sessionId).toBe("sess-1");
|
|
114
|
-
expect(b.speechId).toBe(SPEECH_ID);
|
|
115
|
-
expect(b.provider).toBe("p1");
|
|
116
|
-
expect(b.model).toBe("m1");
|
|
117
|
-
expect(b.region).toBe("r1");
|
|
118
|
-
expect(b.monotonicMs).toBeGreaterThan(0);
|
|
119
|
-
expect(b.cancelled).toBeUndefined();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const v2v = exporter.histograms.find((h) => h.name === "v2v_ms");
|
|
123
|
-
expect(v2v).toBeDefined();
|
|
124
|
-
expect(v2v!.valueMs).toBeGreaterThanOrEqual(0);
|
|
125
|
-
expect(v2v!.tags).toEqual({
|
|
126
|
-
sessionId: "sess-1",
|
|
127
|
-
speechId: SPEECH_ID,
|
|
128
|
-
provider: "p1",
|
|
129
|
-
model: "m1",
|
|
130
|
-
region: "r1",
|
|
131
|
-
cancelled: "false",
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
const thinking = exporter.histograms.find((h) => h.name === "thinking_ms");
|
|
135
|
-
expect(thinking).toBeDefined();
|
|
136
|
-
expect(thinking!.valueMs).toBeGreaterThanOrEqual(0);
|
|
137
|
-
|
|
138
|
-
const agentSpeech = exporter.histograms.find((h) => h.name === "agent_speech_ms");
|
|
139
|
-
expect(agentSpeech).toBeDefined();
|
|
140
|
-
expect(agentSpeech!.valueMs).toBeGreaterThanOrEqual(0);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("tags interruption boundary and histograms as cancelled", async () => {
|
|
145
|
-
await withObserver(async ({ bus, exporter, boundaries }) => {
|
|
146
|
-
bus.push(Route.Critical, {
|
|
147
|
-
kind: "interrupt.detected",
|
|
148
|
-
contextId: SPEECH_ID,
|
|
149
|
-
timestampMs: 2000,
|
|
150
|
-
source: "vad",
|
|
151
|
-
} satisfies InterruptionDetectedPacket);
|
|
152
|
-
await drainBus();
|
|
153
|
-
|
|
154
|
-
const interruption = boundaries.find((b) => b.boundary === "interruption");
|
|
155
|
-
expect(interruption).toBeDefined();
|
|
156
|
-
expect(interruption!.cancelled).toBe(true);
|
|
157
|
-
|
|
158
|
-
const withCancelled = exporter.histograms.filter((h) => h.tags.cancelled === "true");
|
|
159
|
-
for (const h of withCancelled) {
|
|
160
|
-
expect(h.tags).toMatchObject({
|
|
161
|
-
sessionId: "sess-1",
|
|
162
|
-
speechId: SPEECH_ID,
|
|
163
|
-
cancelled: "true",
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("emits agent_started_speaking only once per speechId", async () => {
|
|
170
|
-
await withObserver(async ({ bus, boundaries }) => {
|
|
171
|
-
bus.push(Route.Main, {
|
|
172
|
-
kind: "tts.audio",
|
|
173
|
-
contextId: SPEECH_ID,
|
|
174
|
-
timestampMs: 100,
|
|
175
|
-
audio: new Uint8Array(4),
|
|
176
|
-
sampleRateHz: 16000,
|
|
177
|
-
} satisfies TextToSpeechAudioPacket);
|
|
178
|
-
bus.push(Route.Main, {
|
|
179
|
-
kind: "tts.audio",
|
|
180
|
-
contextId: SPEECH_ID,
|
|
181
|
-
timestampMs: 200,
|
|
182
|
-
audio: new Uint8Array(4),
|
|
183
|
-
sampleRateHz: 16000,
|
|
184
|
-
} satisfies TextToSpeechAudioPacket);
|
|
185
|
-
await drainBus();
|
|
186
|
-
|
|
187
|
-
const started = boundaries.filter((b) => b.boundary === "agent_started_speaking");
|
|
188
|
-
expect(started).toHaveLength(1);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("uses provider/model/region dimensions from STT and TTS packets when present", async () => {
|
|
193
|
-
await withObserver(async ({ bus, exporter, boundaries }) => {
|
|
194
|
-
bus.push(Route.Main, {
|
|
195
|
-
kind: "vad.speech_ended",
|
|
196
|
-
contextId: SPEECH_ID,
|
|
197
|
-
timestampMs: 1100,
|
|
198
|
-
} satisfies VadSpeechEndedPacket);
|
|
199
|
-
bus.push(Route.Main, {
|
|
200
|
-
kind: "eos.turn_complete",
|
|
201
|
-
contextId: SPEECH_ID,
|
|
202
|
-
timestampMs: 1200,
|
|
203
|
-
text: "hello",
|
|
204
|
-
transcripts: [
|
|
205
|
-
{
|
|
206
|
-
kind: "stt.result",
|
|
207
|
-
contextId: SPEECH_ID,
|
|
208
|
-
timestampMs: 1190,
|
|
209
|
-
text: "hello",
|
|
210
|
-
confidence: 0.9,
|
|
211
|
-
provider: { name: "deepgram", model: "nova-3", region: "global" },
|
|
212
|
-
},
|
|
213
|
-
],
|
|
214
|
-
} satisfies EndOfSpeechPacket);
|
|
215
|
-
bus.push(Route.Main, {
|
|
216
|
-
kind: "tts.audio",
|
|
217
|
-
contextId: SPEECH_ID,
|
|
218
|
-
timestampMs: 1300,
|
|
219
|
-
audio: new Uint8Array(320),
|
|
220
|
-
sampleRateHz: 16000,
|
|
221
|
-
provider: { name: "cartesia", model: "sonic-3", region: "global", cancelled: false },
|
|
222
|
-
} satisfies TextToSpeechAudioPacket);
|
|
223
|
-
await drainBus();
|
|
224
|
-
|
|
225
|
-
const started = boundaries.find((b) => b.boundary === "agent_started_speaking");
|
|
226
|
-
expect(started).toMatchObject({
|
|
227
|
-
provider: "cartesia",
|
|
228
|
-
model: "sonic-3",
|
|
229
|
-
region: "global",
|
|
230
|
-
});
|
|
231
|
-
const thinking = boundaries.find((b) => b.boundary === "agent_thinking");
|
|
232
|
-
expect(thinking).toMatchObject({
|
|
233
|
-
provider: "deepgram",
|
|
234
|
-
model: "nova-3",
|
|
235
|
-
region: "global",
|
|
236
|
-
});
|
|
237
|
-
expect(exporter.histograms.find((h) => h.name === "v2v_ms")?.tags).toMatchObject({
|
|
238
|
-
provider: "cartesia",
|
|
239
|
-
model: "sonic-3",
|
|
240
|
-
region: "global",
|
|
241
|
-
speechId: SPEECH_ID,
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
InMemoryMetricsExporter,
|
|
7
|
-
monotonicNowMs,
|
|
8
|
-
noopMetricsExporter,
|
|
9
|
-
reconstructTurnTimeline,
|
|
10
|
-
} from "./observability.js";
|
|
11
|
-
import type { TurnBoundaryEventPacket } from "./packets.js";
|
|
12
|
-
|
|
13
|
-
describe("monotonicNowMs", () => {
|
|
14
|
-
it("returns increasing positive numbers", () => {
|
|
15
|
-
const first = monotonicNowMs();
|
|
16
|
-
const second = monotonicNowMs();
|
|
17
|
-
expect(first).toBeGreaterThan(0);
|
|
18
|
-
expect(second).toBeGreaterThanOrEqual(first);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("InMemoryMetricsExporter", () => {
|
|
23
|
-
it("records histogram name, value, and tags", () => {
|
|
24
|
-
const exporter = new InMemoryMetricsExporter();
|
|
25
|
-
exporter.observeHistogram("turn.latency_ms", 42, { sessionId: "s1", boundary: "agent_audio_done" });
|
|
26
|
-
expect(exporter.histograms).toEqual([
|
|
27
|
-
{
|
|
28
|
-
name: "turn.latency_ms",
|
|
29
|
-
valueMs: 42,
|
|
30
|
-
tags: { sessionId: "s1", boundary: "agent_audio_done" },
|
|
31
|
-
},
|
|
32
|
-
]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("records non-negative span duration on end", () => {
|
|
36
|
-
vi.useFakeTimers();
|
|
37
|
-
const exporter = new InMemoryMetricsExporter();
|
|
38
|
-
const handle = exporter.startSpan("turn.process", { sessionId: "s1" });
|
|
39
|
-
vi.advanceTimersByTime(10);
|
|
40
|
-
handle.end();
|
|
41
|
-
expect(exporter.spans).toHaveLength(1);
|
|
42
|
-
expect(exporter.spans[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
|
43
|
-
vi.useRealTimers();
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("noopMetricsExporter", () => {
|
|
48
|
-
it("accepts histogram and span calls without throwing", () => {
|
|
49
|
-
noopMetricsExporter.observeHistogram("ignored", 0, {});
|
|
50
|
-
noopMetricsExporter.startSpan("ignored", {}).end();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("reconstructTurnTimeline", () => {
|
|
55
|
-
function ev(sessionId: string, speechId: string, boundary: TurnBoundaryEventPacket["boundary"], monotonicMs: number, cancelled = false): TurnBoundaryEventPacket {
|
|
56
|
-
return { kind: "obs.turn_boundary", contextId: speechId, timestampMs: 0, boundary, sessionId, speechId, monotonicMs, cancelled };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
it("orders one session's boundaries by monotonic time with inter-boundary deltas", () => {
|
|
60
|
-
const events = [
|
|
61
|
-
ev("sess-A", "turn-1", "agent_started_speaking", 300),
|
|
62
|
-
ev("sess-A", "turn-1", "user_started_speaking", 100),
|
|
63
|
-
ev("sess-B", "turn-9", "user_started_speaking", 150), // other session — excluded
|
|
64
|
-
ev("sess-A", "turn-1", "user_stopped_speaking", 200),
|
|
65
|
-
];
|
|
66
|
-
const timeline = reconstructTurnTimeline(events, "sess-A");
|
|
67
|
-
expect(timeline.map((s) => s.boundary)).toEqual([
|
|
68
|
-
"user_started_speaking",
|
|
69
|
-
"user_stopped_speaking",
|
|
70
|
-
"agent_started_speaking",
|
|
71
|
-
]);
|
|
72
|
-
expect(timeline.map((s) => s.sincePrevMs)).toEqual([0, 100, 100]);
|
|
73
|
-
expect(timeline.every((s) => s.speechId === "turn-1")).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("filters by speechId when provided and surfaces the cancelled flag", () => {
|
|
77
|
-
const events = [
|
|
78
|
-
ev("s", "t1", "user_started_speaking", 10),
|
|
79
|
-
ev("s", "t2", "interruption", 20, true),
|
|
80
|
-
];
|
|
81
|
-
const t2 = reconstructTurnTimeline(events, "s", "t2");
|
|
82
|
-
expect(t2).toHaveLength(1);
|
|
83
|
-
expect(t2[0]!.cancelled).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { reasoningResume, reasoningSuspended } from "./packet-factories.js";
|
|
6
|
-
|
|
7
|
-
describe("reasoningSuspended", () => {
|
|
8
|
-
it("returns a reasoning.suspended packet with the expected shape", () => {
|
|
9
|
-
const pkt = reasoningSuspended("ctx-1", 1234, "run-1", { step: 3 }, "Pause for input.");
|
|
10
|
-
|
|
11
|
-
expect(pkt).toEqual({
|
|
12
|
-
kind: "reasoning.suspended",
|
|
13
|
-
contextId: "ctx-1",
|
|
14
|
-
timestampMs: 1234,
|
|
15
|
-
runId: "run-1",
|
|
16
|
-
prompt: "Pause for input.",
|
|
17
|
-
payload: { step: 3 },
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("reasoningResume", () => {
|
|
23
|
-
it("returns a reasoning.resume packet with the expected shape", () => {
|
|
24
|
-
const pkt = reasoningResume("ctx-1", 5678, "run-1", "user answer");
|
|
25
|
-
|
|
26
|
-
expect(pkt).toEqual({
|
|
27
|
-
kind: "reasoning.resume",
|
|
28
|
-
contextId: "ctx-1",
|
|
29
|
-
timestampMs: 5678,
|
|
30
|
-
runId: "run-1",
|
|
31
|
-
data: "user answer",
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
});
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
});
|