@kuralle-syrinx/core 2.1.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.
Files changed (52) hide show
  1. package/README.md +41 -0
  2. package/package.json +22 -0
  3. package/src/audio/audio.test.ts +285 -0
  4. package/src/audio/index.ts +5 -0
  5. package/src/audio/mulaw.ts +42 -0
  6. package/src/audio/pcm.ts +43 -0
  7. package/src/audio/resample.ts +177 -0
  8. package/src/audio-envelope.test.ts +167 -0
  9. package/src/audio-envelope.ts +143 -0
  10. package/src/conversation-event.ts +62 -0
  11. package/src/error-handler.test.ts +56 -0
  12. package/src/error-handler.ts +149 -0
  13. package/src/idle-timeout.ts +210 -0
  14. package/src/index.ts +215 -0
  15. package/src/init-chain.ts +137 -0
  16. package/src/init-stage-order.ts +79 -0
  17. package/src/latency-filler-fixtures.ts +16 -0
  18. package/src/latency-filler.test.ts +62 -0
  19. package/src/latency-filler.ts +125 -0
  20. package/src/mode-switcher.ts +110 -0
  21. package/src/observability-observer.test.ts +245 -0
  22. package/src/observability-observer.ts +195 -0
  23. package/src/observability.test.ts +85 -0
  24. package/src/observability.ts +93 -0
  25. package/src/packet-factories.test.ts +34 -0
  26. package/src/packet-factories.ts +243 -0
  27. package/src/packets.ts +553 -0
  28. package/src/pipeline-bus.g10.test.ts +145 -0
  29. package/src/pipeline-bus.test.ts +197 -0
  30. package/src/pipeline-bus.ts +369 -0
  31. package/src/plugin-contract.ts +79 -0
  32. package/src/primary-speaker-fixtures.ts +45 -0
  33. package/src/primary-speaker-gate.test.ts +150 -0
  34. package/src/primary-speaker-gate.ts +186 -0
  35. package/src/provider-fallback.test.ts +87 -0
  36. package/src/provider-fallback.ts +88 -0
  37. package/src/reasoner.test.ts +69 -0
  38. package/src/reasoner.ts +54 -0
  39. package/src/retry.test.ts +83 -0
  40. package/src/retry.ts +106 -0
  41. package/src/scheduler.ts +28 -0
  42. package/src/tts-playout-clock.test.ts +125 -0
  43. package/src/tts-playout-clock.ts +116 -0
  44. package/src/turn-arbiter.characterization.test.ts +477 -0
  45. package/src/turn-arbiter.test.ts +567 -0
  46. package/src/turn-arbiter.ts +283 -0
  47. package/src/voice-agent-session-util.ts +240 -0
  48. package/src/voice-agent-session.test.ts +2487 -0
  49. package/src/voice-agent-session.ts +1175 -0
  50. package/src/voice-text.test.ts +81 -0
  51. package/src/voice-text.ts +102 -0
  52. package/tsconfig.json +21 -0
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ LatencyFillerController,
6
+ selectLatencyFillerConnective,
7
+ stripRedundantFillerPrefix,
8
+ } from "./latency-filler.js";
9
+ import { LATENCY_FILLER_FIXTURES } from "./latency-filler-fixtures.js";
10
+
11
+ describe("selectLatencyFillerConnective", () => {
12
+ it("selects question and gratitude connectives from fixtures", () => {
13
+ for (const fixture of LATENCY_FILLER_FIXTURES) {
14
+ const turnIndex = fixture.id === "statement-1" ? 1 : 0;
15
+ expect(selectLatencyFillerConnective(fixture.userText, turnIndex)).toBe(fixture.expectedConnective);
16
+ }
17
+ });
18
+ });
19
+
20
+ describe("stripRedundantFillerPrefix", () => {
21
+ it("removes a duplicated leading connective without leaving a gap", () => {
22
+ expect(stripRedundantFillerPrefix("So,", "So here's the answer.")).toBe("here's the answer.");
23
+ expect(stripRedundantFillerPrefix("Well,", " Well, the deadline passed.")).toBe("the deadline passed.");
24
+ });
25
+
26
+ it("preserves unrelated LLM text", () => {
27
+ expect(stripRedundantFillerPrefix("So,", "The deadline passed.")).toBe("The deadline passed.");
28
+ });
29
+
30
+ it("does not strip filler words embedded in longer words", () => {
31
+ expect(stripRedundantFillerPrefix("So,", "Some people say...")).toBe("Some people say...");
32
+ expect(stripRedundantFillerPrefix("So,", "Social distancing matters")).toBe("Social distancing matters");
33
+ expect(stripRedundantFillerPrefix("Well,", "Wellness is important")).toBe("Wellness is important");
34
+ expect(stripRedundantFillerPrefix("Well,", "Welcome back")).toBe("Welcome back");
35
+ expect(stripRedundantFillerPrefix("Well,", "Wellington is windy")).toBe("Wellington is windy");
36
+ });
37
+
38
+ it("still strips a genuine repeated filler connective", () => {
39
+ expect(stripRedundantFillerPrefix("So,", "So, the plan is simple.")).toBe("the plan is simple.");
40
+ expect(stripRedundantFillerPrefix("Well,", "Well, let me explain.")).toBe("let me explain.");
41
+ });
42
+ });
43
+
44
+ describe("LatencyFillerController", () => {
45
+ it("tracks active filler-only state until splice or cancel", () => {
46
+ const controller = new LatencyFillerController({ enabled: true });
47
+ expect(controller.start("turn-1", "hello")).toBe("So,");
48
+ expect(controller.isFillerOnly("turn-1")).toBe(true);
49
+ expect(controller.spliceLlmDelta("turn-1", "The answer is ready.")).toBe("The answer is ready.");
50
+ expect(controller.isFillerOnly("turn-1")).toBe(false);
51
+ expect(controller.getState("turn-1")?.spliced).toBe(true);
52
+ });
53
+
54
+ it("marks filler cancelled without splicing", () => {
55
+ const controller = new LatencyFillerController({ enabled: true });
56
+ controller.start("turn-1", "hello");
57
+ const cancelled = controller.cancel("turn-1");
58
+ expect(cancelled?.text).toBe("So,");
59
+ expect(controller.isFillerOnly("turn-1")).toBe(false);
60
+ expect(controller.spliceLlmDelta("turn-1", "ignored")).toBe("ignored");
61
+ });
62
+ });
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // VE-03 / G24 — latency-hiding dual-track filler (DDTSR direction).
4
+ // Emits a short discourse connective at endpoint while the main LLM spins up.
5
+
6
+ export const LATENCY_FILLER_CONNECTIVES = [
7
+ "So,",
8
+ "Well,",
9
+ "Right,",
10
+ "Okay,",
11
+ "Hmm,",
12
+ ] as const;
13
+
14
+ export type LatencyFillerConnective = (typeof LATENCY_FILLER_CONNECTIVES)[number];
15
+
16
+ export interface LatencyFillerState {
17
+ readonly text: string;
18
+ active: boolean;
19
+ spliced: boolean;
20
+ cancelled: boolean;
21
+ readonly startedAtMs: number;
22
+ }
23
+
24
+ export interface LatencyFillerConfig {
25
+ readonly enabled?: boolean;
26
+ }
27
+
28
+ export class LatencyFillerController {
29
+ private readonly enabled: boolean;
30
+ private turnIndex = 0;
31
+ private readonly states = new Map<string, LatencyFillerState>();
32
+
33
+ constructor(config: LatencyFillerConfig = {}) {
34
+ this.enabled = config.enabled === true;
35
+ }
36
+
37
+ isEnabled(): boolean {
38
+ return this.enabled;
39
+ }
40
+
41
+ start(contextId: string, userText: string, nowMs = Date.now()): string | null {
42
+ if (!this.enabled) return null;
43
+ if (this.states.has(contextId)) return null;
44
+ const text = selectLatencyFillerConnective(userText, this.turnIndex);
45
+ this.turnIndex += 1;
46
+ this.states.set(contextId, {
47
+ text,
48
+ active: true,
49
+ spliced: false,
50
+ cancelled: false,
51
+ startedAtMs: nowMs,
52
+ });
53
+ return text;
54
+ }
55
+
56
+ getState(contextId: string): LatencyFillerState | undefined {
57
+ return this.states.get(contextId);
58
+ }
59
+
60
+ isActive(contextId: string): boolean {
61
+ const state = this.states.get(contextId);
62
+ return state?.active === true && state.cancelled !== true;
63
+ }
64
+
65
+ isFillerOnly(contextId: string): boolean {
66
+ const state = this.states.get(contextId);
67
+ return state?.active === true && state.spliced !== true && state.cancelled !== true;
68
+ }
69
+
70
+ spliceLlmDelta(contextId: string, delta: string): string {
71
+ const state = this.states.get(contextId);
72
+ if (!state || state.cancelled || state.spliced) return delta;
73
+ state.spliced = true;
74
+ return stripRedundantFillerPrefix(state.text, delta);
75
+ }
76
+
77
+ cancel(contextId: string): LatencyFillerState | null {
78
+ const state = this.states.get(contextId);
79
+ if (!state || state.cancelled) return null;
80
+ state.cancelled = true;
81
+ state.active = false;
82
+ return state;
83
+ }
84
+
85
+ clear(contextId: string): void {
86
+ this.states.delete(contextId);
87
+ }
88
+ }
89
+
90
+ export function selectLatencyFillerConnective(userText: string, turnIndex: number): string {
91
+ const trimmed = userText.trim().toLowerCase();
92
+ if (trimmed.endsWith("?")) return "Well,";
93
+ if (/\b(thanks|thank you)\b/.test(trimmed)) return "Right,";
94
+ const pool = LATENCY_FILLER_CONNECTIVES;
95
+ return pool[((turnIndex % pool.length) + pool.length) % pool.length]!;
96
+ }
97
+
98
+ function fillerPrefixBoundary(lowerBody: string, prefixLen: number): boolean {
99
+ if (prefixLen >= lowerBody.length) return true;
100
+ const next = lowerBody[prefixLen]!;
101
+ return /[\s,.!?…:;—\-]/.test(next);
102
+ }
103
+
104
+ export function stripRedundantFillerPrefix(fillerText: string, llmText: string): string {
105
+ const fillerWord = fillerText.replace(/[.,!?…\s]+$/g, "").trim().toLowerCase();
106
+ if (!fillerWord) return llmText;
107
+
108
+ const body = llmText.trimStart();
109
+ const lowerBody = body.toLowerCase();
110
+
111
+ if (lowerBody.startsWith(fillerWord) && fillerPrefixBoundary(lowerBody, fillerWord.length)) {
112
+ let rest = body.slice(fillerWord.length);
113
+ rest = rest.replace(/^[\s,]+/, "");
114
+ return rest;
115
+ }
116
+
117
+ const withPunct = `${fillerWord},`;
118
+ if (lowerBody.startsWith(withPunct) && fillerPrefixBoundary(lowerBody, withPunct.length)) {
119
+ let rest = body.slice(withPunct.length);
120
+ rest = rest.replace(/^[\s,]+/, "");
121
+ return rest;
122
+ }
123
+
124
+ return llmText;
125
+ }
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Mode Switcher
4
+ //
5
+ // Handles text ↔ audio mode transitions mid-session.
6
+ // Text→Audio: serial init of audio pipeline (STT, TTS, VAD, EOS, Denoiser)
7
+ // Audio→Text: confirm text mode immediately, teardown audio components in background
8
+ //
9
+ // Design decision: audio→text confirms immediately so the user can send text
10
+ // without waiting for STT/TTS/VAD/EOS WebSocket connections to close.
11
+
12
+ import type { PipelineBus } from "./pipeline-bus.js";
13
+ import { Route } from "./pipeline-bus.js";
14
+ import type {
15
+ ModeSwitchRequestedPacket,
16
+ ModeSwitchCompletedPacket,
17
+ } from "./packets.js";
18
+ import { runInitChain, type InitStep as InitStepType } from "./init-chain.js";
19
+
20
+ // =============================================================================
21
+ // Types
22
+ // =============================================================================
23
+
24
+ export interface ModeSwitchHandlers {
25
+ /** Steps to run when switching from text to audio mode. */
26
+ textToAudioSteps: InitStepType[];
27
+ /** Steps to run when switching from audio to text mode (teardown). */
28
+ audioToTextCleanups: Array<() => Promise<void>>;
29
+ }
30
+
31
+ // =============================================================================
32
+ // Switcher
33
+ // =============================================================================
34
+
35
+ export class ModeSwitcher {
36
+ private currentMode: "text" | "audio";
37
+ private readonly bus: PipelineBus;
38
+ private handlers: ModeSwitchHandlers | null = null;
39
+
40
+ constructor(bus: PipelineBus, initialMode: "text" | "audio" = "audio") {
41
+ this.bus = bus;
42
+ this.currentMode = initialMode;
43
+ }
44
+
45
+ /** Register the init/teardown steps. Call once during session setup. */
46
+ register(handlers: ModeSwitchHandlers): void {
47
+ this.handlers = handlers;
48
+ }
49
+
50
+ /** Get the current mode. */
51
+ get mode(): "text" | "audio" {
52
+ return this.currentMode;
53
+ }
54
+
55
+ /** Handle a mode switch request from the bus. */
56
+ async handleSwitchRequested(pkt: ModeSwitchRequestedPacket): Promise<void> {
57
+ if (pkt.mode === this.currentMode) return;
58
+ if (!this.handlers) return;
59
+
60
+ if (pkt.mode === "audio") {
61
+ await this.switchToAudio(pkt.contextId);
62
+ } else {
63
+ await this.switchToText(pkt.contextId);
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Private
69
+ // ---------------------------------------------------------------------------
70
+
71
+ private async switchToAudio(contextId: string): Promise<void> {
72
+ if (!this.handlers) return;
73
+
74
+ try {
75
+ await runInitChain(this.bus, this.handlers.textToAudioSteps);
76
+ this.currentMode = "audio";
77
+
78
+ const completed: ModeSwitchCompletedPacket = {
79
+ kind: "mode.switch_completed",
80
+ contextId,
81
+ timestampMs: Date.now(),
82
+ mode: "audio",
83
+ };
84
+ this.bus.push(Route.Main, completed);
85
+ } catch (err) {
86
+ // Init chain already emitted init.failed — re-throw for session handler
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ private async switchToText(contextId: string): Promise<void> {
92
+ // Confirm text mode immediately — don't wait for teardown
93
+ this.currentMode = "text";
94
+
95
+ const completed: ModeSwitchCompletedPacket = {
96
+ kind: "mode.switch_completed",
97
+ contextId,
98
+ timestampMs: Date.now(),
99
+ mode: "text",
100
+ };
101
+ this.bus.push(Route.Main, completed);
102
+
103
+ // Teardown audio components in background
104
+ if (this.handlers?.audioToTextCleanups) {
105
+ void Promise.allSettled(
106
+ this.handlers.audioToTextCleanups.map((fn) => fn()),
107
+ );
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,245 @@
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
+ });