@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.
- 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 +40 -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 +24 -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 +439 -2
- package/src/voice-agent-session.ts +232 -22
|
@@ -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({
|
|
@@ -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 }> = [];
|