@kuralle-syrinx/browser-client 4.4.0 → 4.5.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/dist/agent-state.d.ts +35 -0
  2. package/dist/agent-state.d.ts.map +1 -0
  3. package/dist/agent-state.js +76 -0
  4. package/dist/agent-state.js.map +1 -0
  5. package/dist/audio.d.ts +61 -0
  6. package/dist/audio.d.ts.map +1 -0
  7. package/dist/audio.js +276 -0
  8. package/dist/audio.js.map +1 -0
  9. package/dist/browser-opus.d.ts +41 -0
  10. package/dist/browser-opus.d.ts.map +1 -0
  11. package/dist/browser-opus.js +81 -0
  12. package/dist/browser-opus.js.map +1 -0
  13. package/dist/index.d.ts +270 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +744 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/session-metrics.d.ts +28 -0
  18. package/dist/session-metrics.d.ts.map +1 -0
  19. package/dist/session-metrics.js +60 -0
  20. package/dist/session-metrics.js.map +1 -0
  21. package/dist/session-record.d.ts +112 -0
  22. package/dist/session-record.d.ts.map +1 -0
  23. package/dist/session-record.js +181 -0
  24. package/dist/session-record.js.map +1 -0
  25. package/dist/transport.d.ts +16 -0
  26. package/dist/transport.d.ts.map +1 -0
  27. package/dist/transport.js +3 -0
  28. package/dist/transport.js.map +1 -0
  29. package/dist/turn-recorder.d.ts +56 -0
  30. package/dist/turn-recorder.d.ts.map +1 -0
  31. package/dist/turn-recorder.js +168 -0
  32. package/dist/turn-recorder.js.map +1 -0
  33. package/dist/turn-timeline.d.ts +51 -0
  34. package/dist/turn-timeline.d.ts.map +1 -0
  35. package/dist/turn-timeline.js +92 -0
  36. package/dist/turn-timeline.js.map +1 -0
  37. package/dist/websocket-transport.d.ts +20 -0
  38. package/dist/websocket-transport.d.ts.map +1 -0
  39. package/dist/websocket-transport.js +92 -0
  40. package/dist/websocket-transport.js.map +1 -0
  41. package/package.json +35 -11
  42. package/src/agent-state.test.ts +143 -0
  43. package/src/audio.test.ts +130 -0
  44. package/src/index.test.ts +842 -0
  45. package/src/jitter-buffer.test.ts +275 -0
  46. package/src/session-metrics.test.ts +102 -0
  47. package/src/session-record.test.ts +219 -0
  48. package/src/studio-page.test.ts +69 -0
  49. package/src/transport.test.ts +233 -0
  50. package/src/turn-recorder.test.ts +138 -0
  51. package/src/turn-timeline.test.ts +151 -0
  52. package/index.html +0 -1073
@@ -0,0 +1,233 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
5
+ import { decodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
6
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
7
+ import { SyrinxBrowserClient } from "./index.js";
8
+ import type { ClientTransport, ClientTransportHandlers } from "./transport.js";
9
+ import * as browserOpus from "./browser-opus.js";
10
+ import { pickBrowserWireCodec, createBrowserOpusCodec } from "./browser-opus.js";
11
+
12
+ async function pollUntil(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
13
+ const deadline = Date.now() + timeoutMs;
14
+ while (Date.now() < deadline) {
15
+ if (predicate()) return;
16
+ await new Promise((resolve) => setTimeout(resolve, 10));
17
+ }
18
+ throw new Error(`Timed out waiting for ${label}`);
19
+ }
20
+
21
+ class FakeTransport implements ClientTransport {
22
+ readonly sent: unknown[] = [];
23
+ private handlers: ClientTransportHandlers = {};
24
+ private open = false;
25
+
26
+ get connected(): boolean {
27
+ return this.open;
28
+ }
29
+
30
+ setHandlers(handlers: ClientTransportHandlers): void {
31
+ this.handlers = handlers;
32
+ }
33
+
34
+ connect(_url: string): void {
35
+ this.open = true;
36
+ this.handlers.onOpen?.();
37
+ }
38
+
39
+ disconnect(): void {
40
+ this.open = false;
41
+ this.handlers.onClose?.(1000, "");
42
+ }
43
+
44
+ sendAudio(data: Uint8Array | ArrayBuffer): void {
45
+ this.sent.push(data);
46
+ }
47
+
48
+ sendJson(value: unknown): void {
49
+ this.sent.push(JSON.stringify(value));
50
+ }
51
+
52
+ emitMessage(data: string): void {
53
+ this.handlers.onMessage?.(data);
54
+ }
55
+
56
+ emitAudio(data: ArrayBuffer): void {
57
+ this.handlers.onAudio?.(data);
58
+ }
59
+ }
60
+
61
+ describe("ClientTransport seam", () => {
62
+ it("drives SyrinxBrowserClient through an injected fake transport", () => {
63
+ const transport = new FakeTransport();
64
+ const client = new SyrinxBrowserClient({
65
+ url: "ws://unused/ws",
66
+ transport,
67
+ reconnect: false,
68
+ keepaliveIntervalMs: false,
69
+ });
70
+ const events: string[] = [];
71
+ client.on((event) => events.push(event.type));
72
+ client.connect();
73
+
74
+ expect(events).toContain("open");
75
+ transport.emitMessage(JSON.stringify({
76
+ type: "ready",
77
+ sessionId: "sess-fake",
78
+ audio: {
79
+ inputSampleRateHz: 16000,
80
+ outputSampleRateHz: 16000,
81
+ encoding: "pcm_s16le",
82
+ channels: 1,
83
+ },
84
+ }));
85
+ client.sendAudioPcm(new Uint8Array([1, 0, 2, 0]), 16000);
86
+ expect(transport.sent.length).toBeGreaterThan(0);
87
+ });
88
+ });
89
+
90
+ describe("browser opus negotiation", () => {
91
+ it("prefers opus when the server advertises it", () => {
92
+ expect(pickBrowserWireCodec(["pcm_s16le", "opus"], true)).toBe("opus");
93
+ expect(pickBrowserWireCodec(["pcm_s16le", "opus"], false)).toBe("pcm_s16le");
94
+ expect(pickBrowserWireCodec(["pcm_s16le"], true)).toBe("pcm_s16le");
95
+ });
96
+
97
+ it("produces opus frames and decodes them back to PCM16 samples", async () => {
98
+ const codec = await createBrowserOpusCodec(48000);
99
+ const pcm = new Int16Array(960);
100
+ pcm[0] = 1000;
101
+ const wire = codec.encodePcm16Frame(pcm, true)[0]!;
102
+ expect(wire.byteLength).toBeGreaterThan(0);
103
+ expect(codec.decodeOpusFrame(wire).length).toBeGreaterThan(0);
104
+ });
105
+
106
+ it("flushes pre-ready uplink audio in negotiated opus, not pcm", async () => {
107
+ const transport = new FakeTransport();
108
+ const client = new SyrinxBrowserClient({
109
+ url: "ws://unused/ws",
110
+ transport,
111
+ reconnect: false,
112
+ keepaliveIntervalMs: false,
113
+ });
114
+ client.connect();
115
+
116
+ const pcm = new Uint8Array(640);
117
+ pcm[0] = 1;
118
+ pcm[1] = 0;
119
+ client.sendAudioPcm(pcm, 16000, { contextId: "turn-early" });
120
+
121
+ expect(transport.sent.filter((entry) => entry instanceof Uint8Array)).toHaveLength(0);
122
+
123
+ transport.emitMessage(JSON.stringify({
124
+ type: "ready",
125
+ sessionId: "sess-opus-early",
126
+ audio: {
127
+ inputSampleRateHz: 16000,
128
+ outputSampleRateHz: 16000,
129
+ encoding: "opus",
130
+ supportedInputCodecs: ["pcm_s16le", "opus"],
131
+ channels: 1,
132
+ binaryEnvelope: "syrinx.audio.v1",
133
+ },
134
+ }));
135
+
136
+ await pollUntil(
137
+ () => transport.sent.some((entry) => {
138
+ if (typeof entry !== "string") return false;
139
+ return (JSON.parse(entry) as { type?: string }).type === "codec_capability";
140
+ }),
141
+ 3_000,
142
+ "codec_capability",
143
+ );
144
+
145
+ const uplink = transport.sent.filter((entry): entry is Uint8Array => entry instanceof Uint8Array);
146
+ expect(uplink.length).toBeGreaterThan(0);
147
+ for (const frame of uplink) {
148
+ expect(decodeSyrinxAudioEnvelope(frame).header.encoding).toBe("opus");
149
+ }
150
+ expect(decodeSyrinxAudioEnvelope(uplink[0]!).header.contextId).toBe("turn-early");
151
+ });
152
+
153
+ it("encodes uplink envelopes as opus after ready negotiation", async () => {
154
+ const transport = new FakeTransport();
155
+ const client = new SyrinxBrowserClient({
156
+ url: "ws://unused/ws",
157
+ transport,
158
+ reconnect: false,
159
+ keepaliveIntervalMs: false,
160
+ });
161
+ client.connect();
162
+ transport.emitMessage(JSON.stringify({
163
+ type: "ready",
164
+ sessionId: "sess-opus",
165
+ audio: {
166
+ inputSampleRateHz: 16000,
167
+ outputSampleRateHz: 16000,
168
+ encoding: "opus",
169
+ supportedInputCodecs: ["pcm_s16le", "opus"],
170
+ channels: 1,
171
+ binaryEnvelope: "syrinx.audio.v1",
172
+ },
173
+ }));
174
+
175
+ await vi.waitFor(() => transport.sent.length > 0, { timeout: 50 }).catch(() => undefined);
176
+
177
+ const pcm = new Uint8Array(640);
178
+ pcm[0] = 1;
179
+ pcm[1] = 0;
180
+ client.sendAudioPcm(pcm, 16000, { contextId: "turn-opus" });
181
+
182
+ await vi.waitFor(() => transport.sent.some((entry) => {
183
+ if (!(entry instanceof Uint8Array)) return false;
184
+ return decodeSyrinxAudioEnvelope(entry).header.encoding === "opus";
185
+ }), { timeout: 3_000 });
186
+
187
+ const envelopeBytes = transport.sent.find((entry): entry is Uint8Array => {
188
+ if (!(entry instanceof Uint8Array)) return false;
189
+ return decodeSyrinxAudioEnvelope(entry).header.encoding === "opus";
190
+ })!;
191
+ const envelope = decodeSyrinxAudioEnvelope(envelopeBytes);
192
+ expect(envelope.header.sampleRateHz).toBe(48000);
193
+ const reference = pcm16BytesToSamples(
194
+ new OpusDecoder({ channels: 1, sample_rate: 48000 }).decode(envelope.audio),
195
+ );
196
+ expect(reference.length).toBeGreaterThan(0);
197
+ });
198
+ });
199
+
200
+ describe("browser downlink codec capability", () => {
201
+ it("reports pcm downlink when opus load fails", async () => {
202
+ vi.spyOn(browserOpus, "createBrowserOpusCodec").mockRejectedValue(new Error("opus unavailable"));
203
+
204
+ const transport = new FakeTransport();
205
+ const client = new SyrinxBrowserClient({
206
+ url: "ws://unused/ws",
207
+ transport,
208
+ reconnect: false,
209
+ keepaliveIntervalMs: false,
210
+ });
211
+ client.connect();
212
+ transport.emitMessage(JSON.stringify({
213
+ type: "ready",
214
+ sessionId: "sess-pcm-downlink",
215
+ audio: {
216
+ inputSampleRateHz: 16000,
217
+ outputSampleRateHz: 16000,
218
+ encoding: "opus",
219
+ supportedInputCodecs: ["pcm_s16le", "opus"],
220
+ channels: 1,
221
+ binaryEnvelope: "syrinx.audio.v1",
222
+ },
223
+ }));
224
+
225
+ await vi.waitFor(() => transport.sent.some((entry) => {
226
+ if (typeof entry !== "string") return false;
227
+ const message = JSON.parse(entry) as { type?: string; downlinkEncoding?: string };
228
+ return message.type === "codec_capability" && message.downlinkEncoding === "pcm_s16le";
229
+ }), { timeout: 3_000 });
230
+
231
+ vi.restoreAllMocks();
232
+ });
233
+ });
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { encodeWav, TurnAudioRecorder } from "./turn-recorder.js";
6
+
7
+ /** Decode a WAV the way a consumer would, so the test proves the bytes, not our own encoder. */
8
+ function decodeWav(bytes: Uint8Array): { sampleRateHz: number; channels: number; bits: number; samples: Int16Array } {
9
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
+ const tag = (offset: number, length: number): string =>
11
+ String.fromCharCode(...bytes.subarray(offset, offset + length));
12
+ expect(tag(0, 4)).toBe("RIFF");
13
+ expect(tag(8, 4)).toBe("WAVE");
14
+ expect(tag(12, 4)).toBe("fmt ");
15
+ expect(tag(36, 4)).toBe("data");
16
+ expect(view.getUint32(4, true)).toBe(bytes.byteLength - 8);
17
+ const dataBytes = view.getUint32(40, true);
18
+ expect(44 + dataBytes).toBe(bytes.byteLength);
19
+ const samples = new Int16Array(dataBytes / 2);
20
+ for (let i = 0; i < samples.length; i += 1) samples[i] = view.getInt16(44 + i * 2, true);
21
+ return {
22
+ sampleRateHz: view.getUint32(24, true),
23
+ channels: view.getUint16(22, true),
24
+ bits: view.getUint16(34, true),
25
+ samples,
26
+ };
27
+ }
28
+
29
+ const frame = (value: number, length = 320): Int16Array => new Int16Array(length).fill(value);
30
+
31
+ const ready = (hz: number) => ({ type: "ready" as const, audio: { inputSampleRateHz: hz } });
32
+
33
+ /** Drive one whole turn: pre-speech silence, speech, then the boundary that names the turn. */
34
+ function runTurn(rec: TurnAudioRecorder, turnId: string, value: number, frames = 5): void {
35
+ rec.onMessage({ type: "speech_started", turnId });
36
+ for (let i = 0; i < frames; i += 1) rec.pushFrame(frame(value));
37
+ rec.onMessage({ type: "turn_complete", turnId, transcript: "x" });
38
+ }
39
+
40
+ describe("encodeWav", () => {
41
+ it("writes a header a decoder accepts, little-endian regardless of host", () => {
42
+ const wav = encodeWav(Int16Array.from([0, 1, -1, 32767, -32768]), 16000);
43
+ const decoded = decodeWav(wav);
44
+ expect(decoded).toMatchObject({ sampleRateHz: 16000, channels: 1, bits: 16 });
45
+ expect([...decoded.samples]).toEqual([0, 1, -1, 32767, -32768]);
46
+ });
47
+
48
+ it("refuses an invalid sample rate rather than writing an undecodable file", () => {
49
+ expect(() => encodeWav(new Int16Array(4), 0)).toThrow(/invalid sampleRateHz/);
50
+ });
51
+ });
52
+
53
+ describe("TurnAudioRecorder", () => {
54
+ it("yields one decodable WAV per turn at the negotiated rate", () => {
55
+ const rec = new TurnAudioRecorder();
56
+ rec.onMessage(ready(24000));
57
+ runTurn(rec, "t1", 100);
58
+ runTurn(rec, "t2", 200);
59
+ runTurn(rec, "t3", 300);
60
+
61
+ expect(rec.list().map((t) => t.turnId)).toEqual(["t1", "t2", "t3"]);
62
+ for (const [turnId, value] of [["t1", 100], ["t2", 200], ["t3", 300]] as const) {
63
+ const wav = rec.getWav(turnId);
64
+ expect(wav, `${turnId} should have audio`).toBeDefined();
65
+ const decoded = decodeWav(wav as Uint8Array);
66
+ // The negotiated rate from `ready`, not the 16k default — a fixture written at the
67
+ // wrong rate transcribes as gibberish.
68
+ expect(decoded.sampleRateHz).toBe(24000);
69
+ expect(decoded.samples.length).toBe(5 * 320);
70
+ expect(decoded.samples[0]).toBe(value);
71
+ }
72
+ });
73
+
74
+ it("keeps the pre-roll so the speech onset is not clipped", () => {
75
+ const rec = new TurnAudioRecorder({ preRollMs: 20 });
76
+ rec.onMessage(ready(16000));
77
+ // 16000 Hz * 20ms = 320 samples of pre-roll retained.
78
+ rec.pushFrame(frame(7, 320));
79
+ rec.onMessage({ type: "speech_started", turnId: "t1" });
80
+ rec.pushFrame(frame(9, 320));
81
+ rec.onMessage({ type: "turn_complete", turnId: "t1", transcript: "x" });
82
+
83
+ const decoded = decodeWav(rec.getWav("t1") as Uint8Array);
84
+ expect(decoded.samples.length).toBe(640);
85
+ expect(decoded.samples[0]).toBe(7); // onset present
86
+ expect(decoded.samples[320]).toBe(9);
87
+ });
88
+
89
+ it("attributes a turn whose id only arrives on the closing message", () => {
90
+ const rec = new TurnAudioRecorder();
91
+ rec.onMessage(ready(16000));
92
+ rec.onMessage({ type: "speech_started" }); // no turnId yet
93
+ rec.pushFrame(frame(5));
94
+ rec.onMessage({ type: "speech_ended" }); // still none — must not be lost
95
+ rec.onMessage({ type: "stt_output", turnId: "late", transcript: "hi" });
96
+ expect(rec.getWav("late")).toBeDefined();
97
+ });
98
+
99
+ it("evicts oldest audio past maxTurns and does not grow unbounded", () => {
100
+ const rec = new TurnAudioRecorder({ maxTurns: 3 });
101
+ rec.onMessage(ready(16000));
102
+ for (let i = 0; i < 25; i += 1) runTurn(rec, `t${String(i)}`, i + 1);
103
+
104
+ expect(rec.list()).toHaveLength(3);
105
+ expect(rec.list().map((t) => t.turnId)).toEqual(["t22", "t23", "t24"]);
106
+ expect(rec.getWav("t0")).toBeUndefined(); // evicted, reported as absent not as silence
107
+ // 3 turns * 5 frames * 320 samples * 2 bytes — flat regardless of session length.
108
+ expect(rec.retainedBytes()).toBe(3 * 5 * 320 * 2);
109
+ });
110
+
111
+ it("caps a single runaway turn and says the tail was dropped", () => {
112
+ const rec = new TurnAudioRecorder({ maxTurnMs: 100 }); // 1600 samples at 16k
113
+ rec.onMessage(ready(16000));
114
+ rec.onMessage({ type: "speech_started", turnId: "stuck" });
115
+ for (let i = 0; i < 100; i += 1) rec.pushFrame(frame(1, 320));
116
+ rec.onMessage({ type: "turn_complete", turnId: "stuck", transcript: "x" });
117
+
118
+ const entry = rec.list()[0];
119
+ expect(entry?.truncated).toBe(true);
120
+ expect(entry?.byteLength).toBeLessThanOrEqual(1600 * 2 + 320 * 2);
121
+ });
122
+
123
+ it("reports duration from the sample count, not from wall clock", () => {
124
+ const rec = new TurnAudioRecorder();
125
+ rec.onMessage(ready(16000));
126
+ runTurn(rec, "t1", 1, 50); // 50 * 320 = 16000 samples = exactly 1s
127
+ expect(rec.list()[0]?.durationMs).toBe(1000);
128
+ });
129
+
130
+ it("reset drops every retained blob", () => {
131
+ const rec = new TurnAudioRecorder();
132
+ rec.onMessage(ready(16000));
133
+ runTurn(rec, "t1", 1);
134
+ rec.reset();
135
+ expect(rec.list()).toHaveLength(0);
136
+ expect(rec.retainedBytes()).toBe(0);
137
+ });
138
+ });
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { buildSessionRecord, type TurnRecord } from "./session-record.js";
6
+ import { buildTurnTimeline, buildTimelines, endpointingMarker, FAST_TURN_FLOOR_MS } from "./turn-timeline.js";
7
+
8
+ const turnWith = (timings: TurnRecord["timings"]): TurnRecord => ({
9
+ turnId: "t1",
10
+ startedAtMs: 0,
11
+ events: [],
12
+ agentText: "",
13
+ toolCalls: [],
14
+ ttsAudioBytes: 0,
15
+ errors: [],
16
+ complete: true,
17
+ droppedEvents: 0,
18
+ ...(timings ? { timings } : {}),
19
+ });
20
+
21
+ describe("turn timeline — segments", () => {
22
+ it("derives consecutive segments from the marks", () => {
23
+ const tl = buildTurnTimeline(
24
+ turnWith({ speechEndMs: 1000, textReadyMs: 1300, firstAudioByteMs: 2500, firstAudioPlayedMs: 2700, lastAudioPlayedMs: 6300, e2eMs: 5300 }),
25
+ );
26
+ expect(tl.segments.map((s) => [s.label, s.durationMs])).toEqual([
27
+ ["Deciding you're done, transcribing, and thinking", 300],
28
+ ["Voice (to first audio)", 1200],
29
+ ["First audio out", 200],
30
+ ["Agent speaking", 3600],
31
+ ]);
32
+ expect(tl.totalMs).toBe(5300);
33
+ });
34
+
35
+ it("starts each segment where the previous ended", () => {
36
+ const tl = buildTurnTimeline(
37
+ turnWith({ speechEndMs: 100, textReadyMs: 200, firstAudioByteMs: 500 }),
38
+ );
39
+ expect(tl.segments.map((s) => s.startMs)).toEqual([0, 100]);
40
+ });
41
+
42
+ it("marks the longest segment as slowest — where the time actually went", () => {
43
+ const tl = buildTurnTimeline(
44
+ turnWith({ speechEndMs: 0, textReadyMs: 100, firstAudioByteMs: 2000, firstAudioPlayedMs: 2100 }),
45
+ );
46
+ expect(tl.segments.filter((s) => s.slowest).map((s) => s.label)).toEqual(["Voice (to first audio)"]);
47
+ });
48
+
49
+ it("uses plain language, never packet names", () => {
50
+ const tl = buildTurnTimeline(turnWith({ speechEndMs: 0, textReadyMs: 100 }));
51
+ const labels = tl.segments.map((s) => s.label).join(" ");
52
+ expect(labels).not.toMatch(/eos|stt_|tts_|turn_complete|packet/i);
53
+ expect(labels).toMatch(/deciding you're done/i);
54
+ });
55
+ });
56
+
57
+ describe("turn timeline — missing data is stated, not faked", () => {
58
+ it("reports no-metrics rather than a zero-length lane", () => {
59
+ // The Workers path emits no `metrics` (LDT-18). An empty lane would read as
60
+ // a zero-latency turn, which is the opposite of the truth.
61
+ const tl = buildTurnTimeline(turnWith(undefined));
62
+ expect(tl.unavailable).toBe("no-metrics");
63
+ expect(tl.segments).toHaveLength(0);
64
+ });
65
+
66
+ it("reports insufficient-marks when only one mark arrived", () => {
67
+ const tl = buildTurnTimeline(turnWith({ speechEndMs: 100, e2eMs: 900 }));
68
+ expect(tl.unavailable).toBe("insufficient-marks");
69
+ expect(tl.totalMs).toBe(900); // still report what is known
70
+ });
71
+
72
+ it("collapses a missing middle mark without corrupting neighbours", () => {
73
+ const tl = buildTurnTimeline(
74
+ turnWith({ speechEndMs: 0, firstAudioByteMs: 1000, lastAudioPlayedMs: 3000 }),
75
+ );
76
+ expect(tl.segments.map((s) => [s.label, s.durationMs])).toEqual([
77
+ ["Voice (to first audio)", 1000],
78
+ ["Agent speaking", 2000],
79
+ ]);
80
+ });
81
+
82
+ it("never produces a negative duration from out-of-order marks", () => {
83
+ const tl = buildTurnTimeline(turnWith({ speechEndMs: 1000, textReadyMs: 400 }));
84
+ expect(tl.segments[0]?.durationMs).toBe(0);
85
+ });
86
+ });
87
+
88
+ describe("turn timeline — the fast-turn floor", () => {
89
+ it("flags an implausibly fast reply instead of celebrating it", () => {
90
+ // 480ms is not "fast" — the endpointer fired while the caller was still talking.
91
+ const tl = buildTurnTimeline(turnWith({ speechEndMs: 0, textReadyMs: 60, firstAudioByteMs: 400, e2eMs: 480 }));
92
+ expect(tl.suspiciouslyFast).toEqual({ totalMs: 480, floorMs: FAST_TURN_FLOOR_MS });
93
+ });
94
+
95
+ it("does not flag a normal turn", () => {
96
+ const tl = buildTurnTimeline(turnWith({ speechEndMs: 0, textReadyMs: 300, firstAudioByteMs: 1500, e2eMs: 2000 }));
97
+ expect(tl.suspiciouslyFast).toBeUndefined();
98
+ });
99
+
100
+ it("does not flag a turn with no measurable total", () => {
101
+ expect(buildTurnTimeline(turnWith({ speechEndMs: 0, textReadyMs: 0, e2eMs: 0 })).suspiciouslyFast).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe("turn timeline — from a real recorded session", () => {
106
+ it("builds one lane per turn off a SessionRecord", () => {
107
+ const rec = buildSessionRecord([
108
+ { message: { type: "agent_chunk", turnId: "t1", text: "a" } as never, atMs: 0 },
109
+ { message: { type: "metrics", turnId: "t1", speechEndMs: 0, textReadyMs: 200, firstAudioByteMs: 900, e2eMs: 1100 } as never, atMs: 1 },
110
+ { message: { type: "agent_chunk", turnId: "t2", text: "b" } as never, atMs: 2 },
111
+ ]);
112
+ const tls = buildTimelines(rec.turns);
113
+ expect(tls).toHaveLength(2);
114
+ expect(tls[0]?.segments).toHaveLength(2);
115
+ expect(tls[1]?.unavailable).toBe("no-metrics"); // t2 never got metrics
116
+ });
117
+ });
118
+
119
+ describe("turn timeline — endpointing marker", () => {
120
+ it("names the owner in plain language, never the raw enum value", () => {
121
+ expect(endpointingMarker("provider_stt", "end_of_speech").text).toMatch(/speech-to-text provider/i);
122
+ expect(endpointingMarker("smart_turn", "end_of_speech").text).toMatch(/smart turn/i);
123
+ // "timer" covers both a realtime front and a session timer fallback, so the
124
+ // label stays honest for either — never assumes the realtime model specifically.
125
+ expect(endpointingMarker("timer", "end_of_speech").text).toMatch(/timer/i);
126
+ // No packet/message names leak into user-facing text.
127
+ for (const owner of ["provider_stt", "smart_turn", "timer"] as const) {
128
+ expect(endpointingMarker(owner, "end_of_speech").text).not.toMatch(/provider_stt|smart_turn|eos\.turn/);
129
+ }
130
+ });
131
+
132
+ it("says force-finalized was a timeout, not a natural endpoint", () => {
133
+ const m = endpointingMarker("provider_stt", "force_finalized");
134
+ expect(m.kind).toBe("endpoint");
135
+ expect(m.text).toMatch(/force-finalized.*timeout/i);
136
+ });
137
+
138
+ it("flags a typed turn and never claims an endpointer fired", () => {
139
+ const m = endpointingMarker("text", "typed");
140
+ expect(m.kind).toBe("typed");
141
+ expect(m.text).toMatch(/typed/i);
142
+ // It must not say an endpointer decided the turn ended — only that none ran.
143
+ expect(m.text).not.toMatch(/decided you had finished|force-finalized|marked the end/i);
144
+ });
145
+
146
+ it("says the cause is unknown rather than guessing when the owner is absent", () => {
147
+ const m = endpointingMarker(undefined, undefined);
148
+ expect(m.kind).toBe("unknown");
149
+ expect(m.text).toMatch(/unknown/i);
150
+ });
151
+ });