@kuralle-syrinx/core 4.0.0 → 4.2.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 +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +41 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +35 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +557 -2
- package/src/voice-agent-session.ts +308 -24
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
4
|
import { VoiceAgentSession } from "./voice-agent-session.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
Route,
|
|
7
|
+
type InteractionDecision,
|
|
8
|
+
type InteractionObservation,
|
|
9
|
+
type InteractionPolicy,
|
|
10
|
+
type PipelineBus,
|
|
11
|
+
type PluginConfig,
|
|
12
|
+
type VoicePlugin,
|
|
13
|
+
} from "./index.js";
|
|
14
|
+
import { InteractionCoordinator } from "./interaction-coordinator.js";
|
|
15
|
+
import type { Scheduler, ScheduledCallback } from "./scheduler.js";
|
|
16
|
+
import { ErrorCategory, type InteractionBackchannelPacket } from "./packets.js";
|
|
7
17
|
import type {
|
|
8
18
|
EndOfSpeechAudioPacket,
|
|
9
19
|
RecordAssistantAudioPacket,
|
|
10
20
|
RecordUserAudioPacket,
|
|
11
21
|
SpeechToTextAudioPacket,
|
|
12
22
|
SttInterimPacket,
|
|
23
|
+
SttPartialPacket,
|
|
13
24
|
SttResultPacket,
|
|
14
25
|
EndOfSpeechPacket,
|
|
15
26
|
LlmDeltaPacket,
|
|
@@ -104,6 +115,50 @@ class EndpointingPlugin extends CapturingPlugin {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
class CapturingInteractionPolicy implements InteractionPolicy {
|
|
119
|
+
readonly observations: InteractionObservation[] = [];
|
|
120
|
+
readonly resetContextIds: string[] = [];
|
|
121
|
+
initializeCount = 0;
|
|
122
|
+
closeCount = 0;
|
|
123
|
+
initializedConfig: Record<string, unknown> | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
private readonly decide: (observation: InteractionObservation) => readonly InteractionDecision[] = () => [],
|
|
127
|
+
) {}
|
|
128
|
+
|
|
129
|
+
async initialize(config: Record<string, unknown>): Promise<void> {
|
|
130
|
+
this.initializeCount += 1;
|
|
131
|
+
this.initializedConfig = config;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
observe(observation: InteractionObservation): readonly InteractionDecision[] {
|
|
135
|
+
this.observations.push(observation);
|
|
136
|
+
return this.decide(observation);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
reset(contextId: string): void {
|
|
140
|
+
this.resetContextIds.push(contextId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async close(): Promise<void> {
|
|
144
|
+
this.closeCount += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Records scheduled/cancelled timers without firing them, for leak assertions. */
|
|
149
|
+
class RecordingScheduler implements Scheduler {
|
|
150
|
+
readonly timers = new Map<string, { delayMs: number; cb: ScheduledCallback }>();
|
|
151
|
+
schedule(key: string, delayMs: number, cb: ScheduledCallback): void {
|
|
152
|
+
this.timers.set(key, { delayMs, cb });
|
|
153
|
+
}
|
|
154
|
+
cancel(key: string): void {
|
|
155
|
+
this.timers.delete(key);
|
|
156
|
+
}
|
|
157
|
+
pendingKeys(prefix: string): string[] {
|
|
158
|
+
return [...this.timers.keys()].filter((k) => k.startsWith(prefix));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
class InterruptAwareStreamingTtsPlugin implements VoicePlugin {
|
|
108
163
|
private bus: PipelineBus | null = null;
|
|
109
164
|
private interval: ReturnType<typeof setInterval> | null = null;
|
|
@@ -192,6 +247,147 @@ async function enrollPrimarySpeaker(
|
|
|
192
247
|
}
|
|
193
248
|
|
|
194
249
|
describe("VoiceAgentSession", () => {
|
|
250
|
+
it("owns an injected policy lifecycle and feeds decoded audio plus playout observations", async () => {
|
|
251
|
+
const policy = new CapturingInteractionPolicy();
|
|
252
|
+
const session = new VoiceAgentSession({
|
|
253
|
+
plugins: {},
|
|
254
|
+
interactionPolicy: policy,
|
|
255
|
+
interactionPolicyConfig: { model_path: "/tmp/policy.onnx" },
|
|
256
|
+
});
|
|
257
|
+
await session.start();
|
|
258
|
+
|
|
259
|
+
session.bus.push(Route.Main, {
|
|
260
|
+
kind: "user.audio_received",
|
|
261
|
+
contextId: "turn-policy",
|
|
262
|
+
timestampMs: 1000,
|
|
263
|
+
audio: new Uint8Array([0x34, 0x12, 0xcc, 0xff]),
|
|
264
|
+
} satisfies UserAudioReceivedPacket);
|
|
265
|
+
session.bus.push(Route.Main, {
|
|
266
|
+
kind: "tts.audio",
|
|
267
|
+
contextId: "turn-policy",
|
|
268
|
+
timestampMs: 1100,
|
|
269
|
+
audio: new Uint8Array(640),
|
|
270
|
+
sampleRateHz: 16000,
|
|
271
|
+
} satisfies TextToSpeechAudioPacket);
|
|
272
|
+
session.bus.push(Route.Main, {
|
|
273
|
+
kind: "tts.playout_progress",
|
|
274
|
+
contextId: "turn-policy",
|
|
275
|
+
timestampMs: 1200,
|
|
276
|
+
playedOutMs: 20,
|
|
277
|
+
complete: true,
|
|
278
|
+
} satisfies TextToSpeechPlayoutProgressPacket);
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
280
|
+
|
|
281
|
+
expect(policy.initializeCount).toBe(1);
|
|
282
|
+
expect(policy.initializedConfig).toEqual({ model_path: "/tmp/policy.onnx" });
|
|
283
|
+
const audioFrame = policy.observations.find((observation) => observation.kind === "audio_frame");
|
|
284
|
+
expect(audioFrame).toMatchObject({ contextId: "turn-policy", timestampMs: 1000, sampleRateHz: 16000 });
|
|
285
|
+
expect(audioFrame?.kind === "audio_frame" ? [...(audioFrame.audio ?? [])] : []).toEqual([4660, -52]);
|
|
286
|
+
expect(policy.observations.filter((observation) => observation.kind === "playout_tick")).toEqual([
|
|
287
|
+
expect.objectContaining({
|
|
288
|
+
contextId: "turn-policy",
|
|
289
|
+
ttsActive: true,
|
|
290
|
+
sampleRateHz: 16000,
|
|
291
|
+
audio: expect.any(Int16Array),
|
|
292
|
+
}),
|
|
293
|
+
expect.objectContaining({ contextId: "turn-policy", playedOutMs: 20, ttsActive: false }),
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
await closeSession(session);
|
|
297
|
+
expect(policy.closeCount).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("threads the packet's true sample rate to the injected policy audio_frame (not a hardcoded 16k)", async () => {
|
|
301
|
+
const policy = new CapturingInteractionPolicy();
|
|
302
|
+
const session = new VoiceAgentSession({ plugins: {}, interactionPolicy: policy });
|
|
303
|
+
await session.start();
|
|
304
|
+
|
|
305
|
+
session.bus.push(Route.Main, {
|
|
306
|
+
kind: "user.audio_received",
|
|
307
|
+
contextId: "turn-rate",
|
|
308
|
+
timestampMs: 2000,
|
|
309
|
+
audio: new Uint8Array([0x34, 0x12, 0xcc, 0xff]),
|
|
310
|
+
sampleRateHz: 24000,
|
|
311
|
+
} satisfies UserAudioReceivedPacket);
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
313
|
+
|
|
314
|
+
const audioFrame = policy.observations.find((o) => o.kind === "audio_frame");
|
|
315
|
+
expect(audioFrame).toMatchObject({ contextId: "turn-rate", sampleRateHz: 24000 });
|
|
316
|
+
|
|
317
|
+
await closeSession(session);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("cancels a pending interaction playout timer on close even after its turn completed", async () => {
|
|
321
|
+
const scheduler = new RecordingScheduler();
|
|
322
|
+
const policy = new CapturingInteractionPolicy();
|
|
323
|
+
const session = new VoiceAgentSession({ plugins: {}, interactionPolicy: policy, scheduler });
|
|
324
|
+
await session.start();
|
|
325
|
+
|
|
326
|
+
session.bus.push(Route.Main, {
|
|
327
|
+
kind: "tts.audio",
|
|
328
|
+
contextId: "turn-x",
|
|
329
|
+
timestampMs: 1000,
|
|
330
|
+
audio: new Uint8Array(640),
|
|
331
|
+
sampleRateHz: 16000,
|
|
332
|
+
} satisfies TextToSpeechAudioPacket);
|
|
333
|
+
session.bus.push(Route.Main, {
|
|
334
|
+
kind: "tts.end",
|
|
335
|
+
contextId: "turn-x",
|
|
336
|
+
timestampMs: 1100,
|
|
337
|
+
} satisfies TextToSpeechEndPacket);
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
339
|
+
expect(scheduler.pendingKeys("interaction.playout:")).toEqual(["interaction.playout:turn-x"]);
|
|
340
|
+
|
|
341
|
+
// Turn completes (telephony reuses the contextId) — this deletes turn-x from
|
|
342
|
+
// firstTtsAudioFired, orphaning the timer from the old stop() cancel loop.
|
|
343
|
+
session.bus.push(Route.Main, {
|
|
344
|
+
kind: "eos.turn_complete",
|
|
345
|
+
contextId: "turn-x",
|
|
346
|
+
timestampMs: 1200,
|
|
347
|
+
text: "done",
|
|
348
|
+
transcripts: [],
|
|
349
|
+
} satisfies EndOfSpeechPacket);
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
351
|
+
expect(scheduler.pendingKeys("interaction.playout:")).toEqual(["interaction.playout:turn-x"]);
|
|
352
|
+
|
|
353
|
+
await session.close();
|
|
354
|
+
expect(scheduler.pendingKeys("interaction.playout:")).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("does not initialize an injected policy when full-duplex defer mode owns interaction", async () => {
|
|
358
|
+
const policy = new CapturingInteractionPolicy();
|
|
359
|
+
const session = new VoiceAgentSession({ plugins: {}, interactionPolicy: policy, fullDuplex: true });
|
|
360
|
+
await session.start();
|
|
361
|
+
await closeSession(session);
|
|
362
|
+
|
|
363
|
+
expect(policy.initializeCount).toBe(0);
|
|
364
|
+
expect(policy.closeCount).toBe(0);
|
|
365
|
+
expect(policy.observations).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("makes an injected policy the sole endpoint owner while keeping provider STT initialized", async () => {
|
|
369
|
+
const policy = new CapturingInteractionPolicy();
|
|
370
|
+
const provider = new EndpointingPlugin({
|
|
371
|
+
owner: "provider_stt",
|
|
372
|
+
disableConfig: { emit_eos_on_final: false },
|
|
373
|
+
});
|
|
374
|
+
const legacySmartTurn = new EndpointingPlugin({ owner: "smart_turn" });
|
|
375
|
+
const session = new VoiceAgentSession({
|
|
376
|
+
plugins: { stt: { emit_eos_on_final: true }, eos: {} },
|
|
377
|
+
endpointingOwner: "smart_turn",
|
|
378
|
+
interactionPolicy: policy,
|
|
379
|
+
});
|
|
380
|
+
session.registerPlugin("stt", provider);
|
|
381
|
+
session.registerPlugin("eos", legacySmartTurn);
|
|
382
|
+
await session.start();
|
|
383
|
+
|
|
384
|
+
expect(provider.initializeCount).toBe(1);
|
|
385
|
+
expect(provider.config).toEqual({ emit_eos_on_final: false });
|
|
386
|
+
expect(legacySmartTurn.initializeCount).toBe(0);
|
|
387
|
+
expect(policy.initializeCount).toBe(1);
|
|
388
|
+
await closeSession(session);
|
|
389
|
+
});
|
|
390
|
+
|
|
195
391
|
it("passes configured plugin options to each plugin during initialization", async () => {
|
|
196
392
|
const plugin = new CapturingPlugin();
|
|
197
393
|
const session = new VoiceAgentSession({
|
|
@@ -370,6 +566,151 @@ describe("VoiceAgentSession", () => {
|
|
|
370
566
|
await closeSession(session);
|
|
371
567
|
});
|
|
372
568
|
|
|
569
|
+
it("emits a decomposed turn_latency session event on first TTS audio", async () => {
|
|
570
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
571
|
+
const events: Array<Record<string, unknown>> = [];
|
|
572
|
+
session.on("turn_latency", (event) => {
|
|
573
|
+
events.push(event);
|
|
574
|
+
});
|
|
575
|
+
await session.start();
|
|
576
|
+
|
|
577
|
+
const t0 = 100_000;
|
|
578
|
+
session.bus.push(Route.Main, { kind: "vad.speech_ended", contextId: "turn-1", timestampMs: t0 });
|
|
579
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
580
|
+
session.bus.push(Route.Main, {
|
|
581
|
+
kind: "eos.turn_complete",
|
|
582
|
+
contextId: "turn-1",
|
|
583
|
+
timestampMs: t0 + 200,
|
|
584
|
+
text: "what are the lab fees",
|
|
585
|
+
transcripts: [],
|
|
586
|
+
});
|
|
587
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
588
|
+
session.bus.push(Route.Main, { kind: "llm.delta", contextId: "turn-1", timestampMs: t0 + 900, text: "The fee is ten dollars." });
|
|
589
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
590
|
+
session.bus.push(Route.Main, {
|
|
591
|
+
kind: "tts.audio",
|
|
592
|
+
contextId: "turn-1",
|
|
593
|
+
timestampMs: t0 + 1150,
|
|
594
|
+
audio: new Uint8Array(320),
|
|
595
|
+
sampleRateHz: 16000,
|
|
596
|
+
});
|
|
597
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
598
|
+
|
|
599
|
+
expect(events).toHaveLength(1);
|
|
600
|
+
expect(events[0]).toEqual({
|
|
601
|
+
tsMs: expect.any(Number),
|
|
602
|
+
turnId: "turn-1",
|
|
603
|
+
ttfaMs: 1150,
|
|
604
|
+
eouDelayMs: 200,
|
|
605
|
+
llmTtftMs: 700,
|
|
606
|
+
ttsTtfbMs: 250,
|
|
607
|
+
fillerUsed: false,
|
|
608
|
+
backchannelUsed: false,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
await closeSession(session);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("turn_latency anchors TTFA to eos when no VAD speech-end was seen, and marks filler turns", async () => {
|
|
615
|
+
const session = new VoiceAgentSession({ plugins: {}, latencyFillerEnabled: true });
|
|
616
|
+
const events: Array<Record<string, unknown>> = [];
|
|
617
|
+
session.on("turn_latency", (event) => {
|
|
618
|
+
events.push(event);
|
|
619
|
+
});
|
|
620
|
+
await session.start();
|
|
621
|
+
|
|
622
|
+
const t0 = 200_000;
|
|
623
|
+
session.bus.push(Route.Main, {
|
|
624
|
+
kind: "eos.turn_complete",
|
|
625
|
+
contextId: "turn-2",
|
|
626
|
+
timestampMs: t0,
|
|
627
|
+
text: "tell me about registration holds",
|
|
628
|
+
transcripts: [],
|
|
629
|
+
});
|
|
630
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
631
|
+
session.bus.push(Route.Main, {
|
|
632
|
+
kind: "tts.audio",
|
|
633
|
+
contextId: "turn-2",
|
|
634
|
+
timestampMs: t0 + 400,
|
|
635
|
+
audio: new Uint8Array(320),
|
|
636
|
+
sampleRateHz: 16000,
|
|
637
|
+
});
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
639
|
+
|
|
640
|
+
expect(events).toHaveLength(1);
|
|
641
|
+
expect(events[0]).toMatchObject({
|
|
642
|
+
turnId: "turn-2",
|
|
643
|
+
ttfaMs: 400,
|
|
644
|
+
fillerUsed: true,
|
|
645
|
+
backchannelUsed: false,
|
|
646
|
+
});
|
|
647
|
+
expect(events[0]!["eouDelayMs"]).toBeUndefined();
|
|
648
|
+
|
|
649
|
+
await closeSession(session);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("turn_latency is not emitted for an interrupted turn", async () => {
|
|
653
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
654
|
+
const events: unknown[] = [];
|
|
655
|
+
session.on("turn_latency", (event) => {
|
|
656
|
+
events.push(event);
|
|
657
|
+
});
|
|
658
|
+
await session.start();
|
|
659
|
+
|
|
660
|
+
const t0 = 300_000;
|
|
661
|
+
session.bus.push(Route.Main, {
|
|
662
|
+
kind: "eos.turn_complete",
|
|
663
|
+
contextId: "turn-3",
|
|
664
|
+
timestampMs: t0,
|
|
665
|
+
text: "hello there",
|
|
666
|
+
transcripts: [],
|
|
667
|
+
});
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
669
|
+
session.bus.push(Route.Critical, {
|
|
670
|
+
kind: "interrupt.detected",
|
|
671
|
+
contextId: "turn-3",
|
|
672
|
+
timestampMs: t0 + 100,
|
|
673
|
+
});
|
|
674
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
675
|
+
session.bus.push(Route.Main, {
|
|
676
|
+
kind: "tts.audio",
|
|
677
|
+
contextId: "turn-3",
|
|
678
|
+
timestampMs: t0 + 300,
|
|
679
|
+
audio: new Uint8Array(320),
|
|
680
|
+
sampleRateHz: 16000,
|
|
681
|
+
});
|
|
682
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
683
|
+
|
|
684
|
+
expect(events).toHaveLength(0);
|
|
685
|
+
|
|
686
|
+
await closeSession(session);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("IP-C3: tool_call_cue.delayed emits interaction.backchannel end-to-end", async () => {
|
|
690
|
+
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
|
|
691
|
+
const backchannels: InteractionBackchannelPacket[] = [];
|
|
692
|
+
session.bus.on("interaction.backchannel", (pkt) => {
|
|
693
|
+
backchannels.push(pkt as InteractionBackchannelPacket);
|
|
694
|
+
});
|
|
695
|
+
await session.start();
|
|
696
|
+
|
|
697
|
+
session.bus.push(Route.Main, {
|
|
698
|
+
kind: "llm.tool_call",
|
|
699
|
+
contextId: "turn-1",
|
|
700
|
+
timestampMs: Date.now(),
|
|
701
|
+
toolId: "t1",
|
|
702
|
+
toolName: "consult_knowledge",
|
|
703
|
+
toolArgs: { query: "fees" },
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 70));
|
|
707
|
+
|
|
708
|
+
expect(backchannels).toHaveLength(1);
|
|
709
|
+
expect(backchannels[0]).toMatchObject({ contextId: "turn-1", cue: "mm_hmm" });
|
|
710
|
+
|
|
711
|
+
await closeSession(session);
|
|
712
|
+
});
|
|
713
|
+
|
|
373
714
|
it("G3/WBS-3: tool-call lifecycle cues fire started → delayed → complete", async () => {
|
|
374
715
|
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
|
|
375
716
|
const cues: Array<{ phase: string; turnId: string; toolId: string; toolName: string; afterMs?: number }> = [];
|
|
@@ -1075,6 +1416,126 @@ describe("VoiceAgentSession", () => {
|
|
|
1075
1416
|
await closeSession(session);
|
|
1076
1417
|
});
|
|
1077
1418
|
|
|
1419
|
+
it("fullDuplex:true runs the interaction policy observe-only (no VAD-driven interrupt)", async () => {
|
|
1420
|
+
const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280, fullDuplex: true });
|
|
1421
|
+
const interrupts: InterruptTtsPacket[] = [];
|
|
1422
|
+
|
|
1423
|
+
await session.start();
|
|
1424
|
+
session.bus.on("interrupt.tts", (pkt) => {
|
|
1425
|
+
interrupts.push(pkt as InterruptTtsPacket);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
session.bus.push(Route.Main, {
|
|
1429
|
+
kind: "tts.audio",
|
|
1430
|
+
contextId: "assistant-turn",
|
|
1431
|
+
timestampMs: Date.now(),
|
|
1432
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
1433
|
+
sampleRateHz: 16000,
|
|
1434
|
+
} satisfies TextToSpeechAudioPacket);
|
|
1435
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1436
|
+
|
|
1437
|
+
const t0 = Date.now();
|
|
1438
|
+
session.bus.push(Route.Main, {
|
|
1439
|
+
kind: "vad.speech_started",
|
|
1440
|
+
contextId: "user",
|
|
1441
|
+
timestampMs: t0,
|
|
1442
|
+
confidence: 0.99,
|
|
1443
|
+
} satisfies VadSpeechStartedPacket);
|
|
1444
|
+
session.bus.push(Route.Main, {
|
|
1445
|
+
kind: "vad.speech_activity",
|
|
1446
|
+
contextId: "user",
|
|
1447
|
+
timestampMs: t0 + 100,
|
|
1448
|
+
isAsync: true,
|
|
1449
|
+
} satisfies VadSpeechActivityPacket);
|
|
1450
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1451
|
+
expect(interrupts).toEqual([]);
|
|
1452
|
+
|
|
1453
|
+
session.bus.push(Route.Main, {
|
|
1454
|
+
kind: "vad.speech_activity",
|
|
1455
|
+
contextId: "user",
|
|
1456
|
+
timestampMs: t0 + 300,
|
|
1457
|
+
isAsync: true,
|
|
1458
|
+
} satisfies VadSpeechActivityPacket);
|
|
1459
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1460
|
+
|
|
1461
|
+
expect(interrupts).toEqual([]);
|
|
1462
|
+
|
|
1463
|
+
await closeSession(session);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it("fullDuplex:true still honors a direct client interrupt (executor survives defer mode) (IP-C2 regression)", async () => {
|
|
1467
|
+
// Defer mode swaps only the coordinator's DRIVE policy to observe-only; the executor stays the
|
|
1468
|
+
// rule policy's arbiter, so a client-initiated "stop" (requestClientInterrupt) must still fire —
|
|
1469
|
+
// the front owning turn-taking does not disable the user's explicit interrupt.
|
|
1470
|
+
const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280, fullDuplex: true });
|
|
1471
|
+
const interrupts: InterruptTtsPacket[] = [];
|
|
1472
|
+
|
|
1473
|
+
await session.start();
|
|
1474
|
+
session.bus.on("interrupt.tts", (pkt) => {
|
|
1475
|
+
interrupts.push(pkt as InterruptTtsPacket);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
session.bus.push(Route.Main, {
|
|
1479
|
+
kind: "tts.audio",
|
|
1480
|
+
contextId: "assistant-turn",
|
|
1481
|
+
timestampMs: Date.now(),
|
|
1482
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
1483
|
+
sampleRateHz: 16000,
|
|
1484
|
+
} satisfies TextToSpeechAudioPacket);
|
|
1485
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1486
|
+
|
|
1487
|
+
session.requestClientInterrupt("assistant-turn");
|
|
1488
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1489
|
+
|
|
1490
|
+
expect(interrupts).toEqual([
|
|
1491
|
+
expect.objectContaining({ kind: "interrupt.tts", contextId: "assistant-turn" }),
|
|
1492
|
+
]);
|
|
1493
|
+
|
|
1494
|
+
await closeSession(session);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("stt.partial carries wordTimings but does NOT itself drive barge-in (IP-C4 no-double-drive guard)", async () => {
|
|
1498
|
+
// IP-C4: stt.partial is the rich-seam carrier; the session caches its wordTimings for the
|
|
1499
|
+
// observation but barge-in stays driven ONLY by stt.interim/stt.result. Pushing sustained
|
|
1500
|
+
// stt.partial (no interim) during active TTS must NOT interrupt — proving no second barge-in driver.
|
|
1501
|
+
const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
|
|
1502
|
+
const interrupts: InterruptTtsPacket[] = [];
|
|
1503
|
+
|
|
1504
|
+
await session.start();
|
|
1505
|
+
session.bus.on("interrupt.tts", (pkt) => {
|
|
1506
|
+
interrupts.push(pkt as InterruptTtsPacket);
|
|
1507
|
+
});
|
|
1508
|
+
session.bus.push(Route.Main, {
|
|
1509
|
+
kind: "tts.audio",
|
|
1510
|
+
contextId: "assistant-turn",
|
|
1511
|
+
timestampMs: Date.now(),
|
|
1512
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
1513
|
+
sampleRateHz: 16000,
|
|
1514
|
+
} satisfies TextToSpeechAudioPacket);
|
|
1515
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1516
|
+
|
|
1517
|
+
const t0 = Date.now();
|
|
1518
|
+
session.bus.push(Route.Main, {
|
|
1519
|
+
kind: "stt.partial",
|
|
1520
|
+
contextId: "user",
|
|
1521
|
+
timestampMs: t0,
|
|
1522
|
+
text: "wait actually I need",
|
|
1523
|
+
wordTimings: [{ word: "wait", startMs: 0, endMs: 200, confidence: 0.9 }],
|
|
1524
|
+
} satisfies SttPartialPacket);
|
|
1525
|
+
session.bus.push(Route.Main, {
|
|
1526
|
+
kind: "stt.partial",
|
|
1527
|
+
contextId: "user",
|
|
1528
|
+
timestampMs: t0 + 400,
|
|
1529
|
+
text: "wait actually I need something else",
|
|
1530
|
+
wordTimings: [{ word: "wait", startMs: 0, endMs: 200, confidence: 0.9 }],
|
|
1531
|
+
} satisfies SttPartialPacket);
|
|
1532
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1533
|
+
|
|
1534
|
+
expect(interrupts).toEqual([]);
|
|
1535
|
+
|
|
1536
|
+
await closeSession(session);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1078
1539
|
it("commits a barge-in from provider STT interim transcripts when no VAD plugin is registered", async () => {
|
|
1079
1540
|
// Cascade deployments with endpointingOwner "provider_stt" (the default) have
|
|
1080
1541
|
// no vad.speech_started producer — interim transcripts during TTS playout are
|
|
@@ -1127,6 +1588,100 @@ describe("VoiceAgentSession", () => {
|
|
|
1127
1588
|
await closeSession(session);
|
|
1128
1589
|
});
|
|
1129
1590
|
|
|
1591
|
+
it("attaches cached stt.partial wordTimings to the next barge-in observation (IP-C4)", async () => {
|
|
1592
|
+
const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
|
|
1593
|
+
const observations: InteractionObservation[] = [];
|
|
1594
|
+
await session.start();
|
|
1595
|
+
|
|
1596
|
+
const interaction = (session as unknown as { interaction: InteractionCoordinator }).interaction;
|
|
1597
|
+
const originalObserve = interaction.observe.bind(interaction);
|
|
1598
|
+
interaction.observe = (obs) => {
|
|
1599
|
+
observations.push(obs);
|
|
1600
|
+
originalObserve(obs);
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
session.bus.push(Route.Main, {
|
|
1604
|
+
kind: "stt.partial",
|
|
1605
|
+
contextId: "user",
|
|
1606
|
+
timestampMs: Date.now(),
|
|
1607
|
+
text: "wait actually",
|
|
1608
|
+
wordTimings: [{ word: "wait", startMs: 100, endMs: 300, confidence: 0.9 }],
|
|
1609
|
+
} satisfies SttPartialPacket);
|
|
1610
|
+
|
|
1611
|
+
session.bus.push(Route.Main, {
|
|
1612
|
+
kind: "stt.interim",
|
|
1613
|
+
contextId: "user",
|
|
1614
|
+
timestampMs: Date.now(),
|
|
1615
|
+
text: "wait actually",
|
|
1616
|
+
} satisfies SttInterimPacket);
|
|
1617
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1618
|
+
|
|
1619
|
+
expect(observations).toEqual(
|
|
1620
|
+
expect.arrayContaining([
|
|
1621
|
+
expect.objectContaining({
|
|
1622
|
+
kind: "stt_partial",
|
|
1623
|
+
text: "wait actually",
|
|
1624
|
+
wordTimings: [{ word: "wait", startMs: 100, endMs: 300, confidence: 0.9 }],
|
|
1625
|
+
}),
|
|
1626
|
+
]),
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
await closeSession(session);
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
it("suppresses a backchannel provider-STT interim through the interaction seam (IP-C1 regression)", async () => {
|
|
1633
|
+
// IP-C1 routed provider-STT barge-in through InteractionCoordinator ->
|
|
1634
|
+
// RuleBasedInteractionPolicy -> TurnArbiter. This pins that the reshaped
|
|
1635
|
+
// chain still SUPPRESSES a sustained backchannel ("okay") rather than cutting
|
|
1636
|
+
// the assistant — the session-level suppression case the policy-unit test
|
|
1637
|
+
// (rule-based.test.ts, vad-driven) and the commit test above do not cover.
|
|
1638
|
+
const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
|
|
1639
|
+
const interrupts: InterruptTtsPacket[] = [];
|
|
1640
|
+
const metrics: string[] = [];
|
|
1641
|
+
|
|
1642
|
+
await session.start();
|
|
1643
|
+
session.bus.on("interrupt.tts", (pkt) => {
|
|
1644
|
+
interrupts.push(pkt as InterruptTtsPacket);
|
|
1645
|
+
});
|
|
1646
|
+
session.bus.on("metric.conversation", (pkt) => {
|
|
1647
|
+
metrics.push((pkt as unknown as { name: string }).name);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
// Assistant is speaking.
|
|
1651
|
+
session.bus.push(Route.Main, {
|
|
1652
|
+
kind: "tts.audio",
|
|
1653
|
+
contextId: "assistant-turn",
|
|
1654
|
+
timestampMs: Date.now(),
|
|
1655
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
1656
|
+
sampleRateHz: 16000,
|
|
1657
|
+
} satisfies TextToSpeechAudioPacket);
|
|
1658
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1659
|
+
|
|
1660
|
+
// Sustained-past-minInterruptionMs backchannel evidence: opens the pending
|
|
1661
|
+
// window, then the second interim (past 280ms) reaches tryCommit -> the
|
|
1662
|
+
// arbiter's backchannel suppression fires instead of an interrupt decision.
|
|
1663
|
+
const t0 = Date.now();
|
|
1664
|
+
session.bus.push(Route.Main, {
|
|
1665
|
+
kind: "stt.interim",
|
|
1666
|
+
contextId: "user",
|
|
1667
|
+
timestampMs: t0,
|
|
1668
|
+
text: "okay",
|
|
1669
|
+
} satisfies SttInterimPacket);
|
|
1670
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1671
|
+
session.bus.push(Route.Main, {
|
|
1672
|
+
kind: "stt.interim",
|
|
1673
|
+
contextId: "user",
|
|
1674
|
+
timestampMs: t0 + 300,
|
|
1675
|
+
text: "okay",
|
|
1676
|
+
} satisfies SttInterimPacket);
|
|
1677
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1678
|
+
|
|
1679
|
+
expect(interrupts).toEqual([]);
|
|
1680
|
+
expect(metrics).toContain("interrupt.suppressed_backchannel");
|
|
1681
|
+
|
|
1682
|
+
await closeSession(session);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1130
1685
|
it("emits interrupt.onset_to_logic_cancel_ms and stamps interrupt.tts/llm with detected onset", async () => {
|
|
1131
1686
|
const session = new VoiceAgentSession({ plugins: {} });
|
|
1132
1687
|
const onsetMetrics: Array<{ name: string; value: string }> = [];
|