@kuralle-syrinx/core 4.1.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.
@@ -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 { Route, type PipelineBus, type PluginConfig, type VoicePlugin } from "./index.js";
6
- import { ErrorCategory } from "./packets.js";
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({
@@ -409,6 +605,7 @@ describe("VoiceAgentSession", () => {
409
605
  llmTtftMs: 700,
410
606
  ttsTtfbMs: 250,
411
607
  fillerUsed: false,
608
+ backchannelUsed: false,
412
609
  });
413
610
 
414
611
  await closeSession(session);
@@ -445,6 +642,7 @@ describe("VoiceAgentSession", () => {
445
642
  turnId: "turn-2",
446
643
  ttfaMs: 400,
447
644
  fillerUsed: true,
645
+ backchannelUsed: false,
448
646
  });
449
647
  expect(events[0]!["eouDelayMs"]).toBeUndefined();
450
648
 
@@ -488,6 +686,31 @@ describe("VoiceAgentSession", () => {
488
686
  await closeSession(session);
489
687
  });
490
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
+
491
714
  it("G3/WBS-3: tool-call lifecycle cues fire started → delayed → complete", async () => {
492
715
  const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
493
716
  const cues: Array<{ phase: string; turnId: string; toolId: string; toolName: string; afterMs?: number }> = [];
@@ -1193,6 +1416,126 @@ describe("VoiceAgentSession", () => {
1193
1416
  await closeSession(session);
1194
1417
  });
1195
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
+
1196
1539
  it("commits a barge-in from provider STT interim transcripts when no VAD plugin is registered", async () => {
1197
1540
  // Cascade deployments with endpointingOwner "provider_stt" (the default) have
1198
1541
  // no vad.speech_started producer — interim transcripts during TTS playout are
@@ -1245,6 +1588,100 @@ describe("VoiceAgentSession", () => {
1245
1588
  await closeSession(session);
1246
1589
  });
1247
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
+
1248
1685
  it("emits interrupt.onset_to_logic_cancel_ms and stamps interrupt.tts/llm with detected onset", async () => {
1249
1686
  const session = new VoiceAgentSession({ plugins: {} });
1250
1687
  const onsetMetrics: Array<{ name: string; value: string }> = [];