@kuralle-syrinx/core 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.
- package/dist/audio/alaw.d.ts +3 -0
- package/dist/audio/alaw.d.ts.map +1 -0
- package/dist/audio/alaw.js +53 -0
- package/dist/audio/alaw.js.map +1 -0
- package/dist/audio/g722.d.ts +39 -0
- package/dist/audio/g722.d.ts.map +1 -0
- package/dist/audio/g722.js +286 -0
- package/dist/audio/g722.js.map +1 -0
- package/dist/audio/index.d.ts +7 -0
- package/dist/audio/index.d.ts.map +1 -0
- package/dist/audio/index.js +8 -0
- package/dist/audio/index.js.map +1 -0
- package/dist/audio/loudness.d.ts +13 -0
- package/dist/audio/loudness.d.ts.map +1 -0
- package/dist/audio/loudness.js +65 -0
- package/dist/audio/loudness.js.map +1 -0
- package/dist/audio/mulaw.d.ts +3 -0
- package/dist/audio/mulaw.d.ts.map +1 -0
- package/dist/audio/mulaw.js +39 -0
- package/dist/audio/mulaw.js.map +1 -0
- package/dist/audio/pcm.d.ts +5 -0
- package/dist/audio/pcm.d.ts.map +1 -0
- package/dist/audio/pcm.js +40 -0
- package/dist/audio/pcm.js.map +1 -0
- package/dist/audio/resample.d.ts +12 -0
- package/dist/audio/resample.d.ts.map +1 -0
- package/dist/audio/resample.js +157 -0
- package/dist/audio/resample.js.map +1 -0
- package/dist/audio-envelope.d.ts +23 -0
- package/dist/audio-envelope.d.ts.map +1 -0
- package/dist/audio-envelope.js +114 -0
- package/dist/audio-envelope.js.map +1 -0
- package/dist/confidence-to-wait.d.ts +12 -0
- package/dist/confidence-to-wait.d.ts.map +1 -0
- package/dist/confidence-to-wait.js +18 -0
- package/dist/confidence-to-wait.js.map +1 -0
- package/dist/conversation-event.d.ts +19 -0
- package/dist/conversation-event.d.ts.map +1 -0
- package/dist/conversation-event.js +44 -0
- package/dist/conversation-event.js.map +1 -0
- package/dist/error-handler.d.ts +35 -0
- package/dist/error-handler.d.ts.map +1 -0
- package/dist/error-handler.js +136 -0
- package/dist/error-handler.js.map +1 -0
- package/dist/idle-timeout.d.ts +69 -0
- package/dist/idle-timeout.d.ts.map +1 -0
- package/dist/idle-timeout.js +149 -0
- package/dist/idle-timeout.js.map +1 -0
- package/dist/incremental-unit.d.ts +17 -0
- package/dist/incremental-unit.d.ts.map +1 -0
- package/dist/incremental-unit.js +5 -0
- package/dist/incremental-unit.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/init-chain.d.ts +35 -0
- package/dist/init-chain.d.ts.map +1 -0
- package/dist/init-chain.js +102 -0
- package/dist/init-chain.js.map +1 -0
- package/dist/init-stage-order.d.ts +5 -0
- package/dist/init-stage-order.d.ts.map +1 -0
- package/dist/init-stage-order.js +74 -0
- package/dist/init-stage-order.js.map +1 -0
- package/dist/interaction-coordinator.d.ts +45 -0
- package/dist/interaction-coordinator.d.ts.map +1 -0
- package/dist/interaction-coordinator.js +197 -0
- package/dist/interaction-coordinator.js.map +1 -0
- package/dist/interaction-policy.d.ts +103 -0
- package/dist/interaction-policy.d.ts.map +1 -0
- package/dist/interaction-policy.js +6 -0
- package/dist/interaction-policy.js.map +1 -0
- package/dist/iu-ledger.d.ts +39 -0
- package/dist/iu-ledger.d.ts.map +1 -0
- package/dist/iu-ledger.js +90 -0
- package/dist/iu-ledger.js.map +1 -0
- package/dist/latency-filler-fixtures.d.ts +7 -0
- package/dist/latency-filler-fixtures.d.ts.map +1 -0
- package/dist/latency-filler-fixtures.js +10 -0
- package/dist/latency-filler-fixtures.js.map +1 -0
- package/dist/latency-filler.d.ts +29 -0
- package/dist/latency-filler.d.ts.map +1 -0
- package/dist/latency-filler.js +102 -0
- package/dist/latency-filler.js.map +1 -0
- package/dist/mode-switcher.d.ts +24 -0
- package/dist/mode-switcher.d.ts.map +1 -0
- package/dist/mode-switcher.js +83 -0
- package/dist/mode-switcher.js.map +1 -0
- package/dist/observability-observer.d.ts +36 -0
- package/dist/observability-observer.d.ts.map +1 -0
- package/dist/observability-observer.js +141 -0
- package/dist/observability-observer.js.map +1 -0
- package/dist/observability.d.ts +68 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +70 -0
- package/dist/observability.js.map +1 -0
- package/dist/packet-factories.d.ts +58 -0
- package/dist/packet-factories.d.ts.map +1 -0
- package/dist/packet-factories.js +181 -0
- package/dist/packet-factories.js.map +1 -0
- package/dist/packets.d.ts +532 -0
- package/dist/packets.d.ts.map +1 -0
- package/dist/packets.js +51 -0
- package/dist/packets.js.map +1 -0
- package/dist/pipeline-bus.d.ts +92 -0
- package/dist/pipeline-bus.d.ts.map +1 -0
- package/dist/pipeline-bus.js +294 -0
- package/dist/pipeline-bus.js.map +1 -0
- package/dist/plugin-contract.d.ts +73 -0
- package/dist/plugin-contract.d.ts.map +1 -0
- package/dist/plugin-contract.js +30 -0
- package/dist/plugin-contract.js.map +1 -0
- package/dist/policies/defer.d.ts +11 -0
- package/dist/policies/defer.d.ts.map +1 -0
- package/dist/policies/defer.js +13 -0
- package/dist/policies/defer.js.map +1 -0
- package/dist/policies/rule-based.d.ts +35 -0
- package/dist/policies/rule-based.d.ts.map +1 -0
- package/dist/policies/rule-based.js +130 -0
- package/dist/policies/rule-based.js.map +1 -0
- package/dist/pricing.d.ts +34 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +108 -0
- package/dist/pricing.js.map +1 -0
- package/dist/primary-speaker-fixtures.d.ts +12 -0
- package/dist/primary-speaker-fixtures.d.ts.map +1 -0
- package/dist/primary-speaker-fixtures.js +37 -0
- package/dist/primary-speaker-fixtures.js.map +1 -0
- package/dist/primary-speaker-gate.d.ts +42 -0
- package/dist/primary-speaker-gate.d.ts.map +1 -0
- package/dist/primary-speaker-gate.js +178 -0
- package/dist/primary-speaker-gate.js.map +1 -0
- package/dist/provider-fallback.d.ts +28 -0
- package/dist/provider-fallback.d.ts.map +1 -0
- package/dist/provider-fallback.js +72 -0
- package/dist/provider-fallback.js.map +1 -0
- package/dist/reasoner-hedge.d.ts +21 -0
- package/dist/reasoner-hedge.d.ts.map +1 -0
- package/dist/reasoner-hedge.js +170 -0
- package/dist/reasoner-hedge.js.map +1 -0
- package/dist/reasoner-route.d.ts +24 -0
- package/dist/reasoner-route.d.ts.map +1 -0
- package/dist/reasoner-route.js +87 -0
- package/dist/reasoner-route.js.map +1 -0
- package/dist/reasoner-session-store.d.ts +18 -0
- package/dist/reasoner-session-store.d.ts.map +1 -0
- package/dist/reasoner-session-store.js +21 -0
- package/dist/reasoner-session-store.js.map +1 -0
- package/dist/reasoner.d.ts +97 -0
- package/dist/reasoner.d.ts.map +1 -0
- package/dist/reasoner.js +9 -0
- package/dist/reasoner.js.map +1 -0
- package/dist/retry.d.ts +31 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +83 -0
- package/dist/retry.js.map +1 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +20 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/spend-cap.d.ts +27 -0
- package/dist/spend-cap.d.ts.map +1 -0
- package/dist/spend-cap.js +38 -0
- package/dist/spend-cap.js.map +1 -0
- package/dist/tts-playout-clock.d.ts +43 -0
- package/dist/tts-playout-clock.d.ts.map +1 -0
- package/dist/tts-playout-clock.js +125 -0
- package/dist/tts-playout-clock.js.map +1 -0
- package/dist/turn-arbiter.d.ts +41 -0
- package/dist/turn-arbiter.d.ts.map +1 -0
- package/dist/turn-arbiter.js +259 -0
- package/dist/turn-arbiter.js.map +1 -0
- package/dist/voice-agent-session-util.d.ts +60 -0
- package/dist/voice-agent-session-util.d.ts.map +1 -0
- package/dist/voice-agent-session-util.js +204 -0
- package/dist/voice-agent-session-util.js.map +1 -0
- package/dist/voice-agent-session.d.ts +435 -0
- package/dist/voice-agent-session.d.ts.map +1 -0
- package/dist/voice-agent-session.js +1597 -0
- package/dist/voice-agent-session.js.map +1 -0
- package/dist/voice-text.d.ts +46 -0
- package/dist/voice-text.d.ts.map +1 -0
- package/dist/voice-text.js +209 -0
- package/dist/voice-text.js.map +1 -0
- package/package.json +19 -6
- package/src/audio/alaw.test.ts +87 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/g722.test.ts +149 -0
- package/src/audio/loudness.test.ts +58 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/error-handler.test.ts +56 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/interaction-coordinator.test.ts +529 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/observability-observer.test.ts +248 -0
- package/src/observability.test.ts +94 -0
- package/src/packet-factories.test.ts +126 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +211 -0
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/rule-based.test.ts +334 -0
- package/src/pricing.test.ts +226 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner.test.ts +69 -0
- package/src/retry.test.ts +83 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/spend-cap.test.ts +114 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +762 -0
- package/src/voice-agent-session.test.ts +4165 -0
- package/src/voice-text.test.ts +156 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { IncrementalUnit, IncrementalUnitId } from "./incremental-unit.js";
|
|
5
|
+
import { InMemoryIuLedger, type IuLedgerAnomaly } from "./iu-ledger.js";
|
|
6
|
+
|
|
7
|
+
function makeId(contextId: string, iuId: string, epoch = 1): IncrementalUnitId {
|
|
8
|
+
return { contextId, iuId, epoch };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeIu(
|
|
12
|
+
contextId: string,
|
|
13
|
+
iuId: string,
|
|
14
|
+
kind: IncrementalUnit["kind"],
|
|
15
|
+
epoch = 1,
|
|
16
|
+
): IncrementalUnit {
|
|
17
|
+
return {
|
|
18
|
+
id: makeId(contextId, iuId, epoch),
|
|
19
|
+
kind,
|
|
20
|
+
state: "hypothesized",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function seedContexts(ledger: InMemoryIuLedger, count: number): IncrementalUnitId {
|
|
25
|
+
const target = makeId("ctx-target", "iu-target");
|
|
26
|
+
for (let i = 0; i < count; i++) {
|
|
27
|
+
ledger.add(makeIu(`ctx-${i}`, `iu-${i}`, "user_turn", i));
|
|
28
|
+
}
|
|
29
|
+
ledger.add(makeIu(target.contextId, target.iuId, "user_turn", target.epoch));
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("InMemoryIuLedger", () => {
|
|
34
|
+
describe("add", () => {
|
|
35
|
+
it("registers a hypothesized IU (happy path)", () => {
|
|
36
|
+
const ledger = new InMemoryIuLedger();
|
|
37
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
38
|
+
ledger.add(iu);
|
|
39
|
+
expect(ledger.get(iu.id)?.state).toBe("hypothesized");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("overwrites an existing iuId with the latest add (failure path: stale hypothesis replaced)", () => {
|
|
43
|
+
const ledger = new InMemoryIuLedger();
|
|
44
|
+
const first = makeIu("ctx-a", "iu-1", "user_turn", 1);
|
|
45
|
+
const second = makeIu("ctx-a", "iu-1", "assistant_response", 2);
|
|
46
|
+
ledger.add(first);
|
|
47
|
+
ledger.add(second);
|
|
48
|
+
expect(ledger.get(first.id)?.kind).toBe("assistant_response");
|
|
49
|
+
expect(ledger.get(first.id)?.id.epoch).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("commit", () => {
|
|
54
|
+
it("transitions hypothesized → committed (happy path)", () => {
|
|
55
|
+
const ledger = new InMemoryIuLedger();
|
|
56
|
+
const iu = makeIu("ctx-a", "iu-1", "assistant_response");
|
|
57
|
+
ledger.add(iu);
|
|
58
|
+
ledger.commit(iu.id);
|
|
59
|
+
expect(ledger.get(iu.id)?.state).toBe("committed");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("records committedPrefix when provided (happy path)", () => {
|
|
63
|
+
const ledger = new InMemoryIuLedger();
|
|
64
|
+
const iu = makeIu("ctx-a", "iu-1", "tts_segment");
|
|
65
|
+
ledger.add(iu);
|
|
66
|
+
ledger.commit(iu.id, { ms: 800, chars: 40 });
|
|
67
|
+
expect(ledger.get(iu.id)?.committedPrefix).toEqual({ ms: 800, chars: 40 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is a no-op on an already-committed IU and fires terminal_op (failure path)", () => {
|
|
71
|
+
const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
|
|
72
|
+
const ledger = new InMemoryIuLedger(onEvent);
|
|
73
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
74
|
+
ledger.add(iu);
|
|
75
|
+
ledger.commit(iu.id);
|
|
76
|
+
ledger.commit(iu.id);
|
|
77
|
+
expect(ledger.get(iu.id)?.state).toBe("committed");
|
|
78
|
+
expect(onEvent).toHaveBeenCalledOnce();
|
|
79
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
80
|
+
kind: "terminal_op",
|
|
81
|
+
op: "commit",
|
|
82
|
+
id: iu.id,
|
|
83
|
+
state: "committed",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not un-commit: revoke after commit is a no-op with terminal_op (failure path)", () => {
|
|
88
|
+
const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
|
|
89
|
+
const ledger = new InMemoryIuLedger(onEvent);
|
|
90
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
91
|
+
ledger.add(iu);
|
|
92
|
+
ledger.commit(iu.id);
|
|
93
|
+
ledger.revoke(iu.id);
|
|
94
|
+
expect(ledger.get(iu.id)?.state).toBe("committed");
|
|
95
|
+
expect(onEvent).toHaveBeenCalledOnce();
|
|
96
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
97
|
+
kind: "terminal_op",
|
|
98
|
+
op: "revoke",
|
|
99
|
+
id: iu.id,
|
|
100
|
+
state: "committed",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fail-open on unknown iuId: no throw, unknown_iu anomaly (failure path)", () => {
|
|
105
|
+
const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
|
|
106
|
+
const ledger = new InMemoryIuLedger(onEvent);
|
|
107
|
+
const id = makeId("missing", "iu-1");
|
|
108
|
+
expect(() => ledger.commit(id)).not.toThrow();
|
|
109
|
+
expect(onEvent).toHaveBeenCalledWith({ kind: "unknown_iu", op: "commit", id });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("revoke", () => {
|
|
114
|
+
it("transitions hypothesized → revoked (happy path)", () => {
|
|
115
|
+
const ledger = new InMemoryIuLedger();
|
|
116
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
117
|
+
ledger.add(iu);
|
|
118
|
+
ledger.revoke(iu.id);
|
|
119
|
+
expect(ledger.get(iu.id)?.state).toBe("revoked");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("is a no-op on an already-revoked IU and fires terminal_op (failure path)", () => {
|
|
123
|
+
const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
|
|
124
|
+
const ledger = new InMemoryIuLedger(onEvent);
|
|
125
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
126
|
+
ledger.add(iu);
|
|
127
|
+
ledger.revoke(iu.id);
|
|
128
|
+
ledger.revoke(iu.id);
|
|
129
|
+
expect(ledger.get(iu.id)?.state).toBe("revoked");
|
|
130
|
+
expect(onEvent).toHaveBeenCalledOnce();
|
|
131
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
132
|
+
kind: "terminal_op",
|
|
133
|
+
op: "revoke",
|
|
134
|
+
id: iu.id,
|
|
135
|
+
state: "revoked",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("fail-open on unknown iuId: no throw, unknown_iu anomaly (failure path)", () => {
|
|
140
|
+
const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
|
|
141
|
+
const ledger = new InMemoryIuLedger(onEvent);
|
|
142
|
+
const id = makeId("missing", "iu-1");
|
|
143
|
+
expect(() => ledger.revoke(id)).not.toThrow();
|
|
144
|
+
expect(onEvent).toHaveBeenCalledWith({ kind: "unknown_iu", op: "revoke", id });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("get", () => {
|
|
149
|
+
it("returns the registered IU (happy path)", () => {
|
|
150
|
+
const ledger = new InMemoryIuLedger();
|
|
151
|
+
const iu = makeIu("ctx-a", "iu-1", "user_turn");
|
|
152
|
+
ledger.add(iu);
|
|
153
|
+
expect(ledger.get(iu.id)).toBe(iu);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for an unknown id without throwing (failure path)", () => {
|
|
157
|
+
const ledger = new InMemoryIuLedger();
|
|
158
|
+
expect(ledger.get(makeId("missing", "iu-1"))).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("latest", () => {
|
|
163
|
+
it("returns the most recently added IU of the requested kind (happy path)", () => {
|
|
164
|
+
const ledger = new InMemoryIuLedger();
|
|
165
|
+
ledger.add(makeIu("ctx-a", "iu-1", "user_turn"));
|
|
166
|
+
ledger.add(makeIu("ctx-a", "iu-2", "assistant_response"));
|
|
167
|
+
ledger.add(makeIu("ctx-a", "iu-3", "user_turn"));
|
|
168
|
+
const latest = ledger.latest("ctx-a", "user_turn");
|
|
169
|
+
expect(latest?.id.iuId).toBe("iu-3");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns undefined when no IU of that kind exists (failure path)", () => {
|
|
173
|
+
const ledger = new InMemoryIuLedger();
|
|
174
|
+
ledger.add(makeIu("ctx-a", "iu-1", "user_turn"));
|
|
175
|
+
expect(ledger.latest("ctx-a", "tts_segment")).toBeUndefined();
|
|
176
|
+
expect(ledger.latest("missing", "user_turn")).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("clear", () => {
|
|
181
|
+
it("removes only the requested context (happy path)", () => {
|
|
182
|
+
const ledger = new InMemoryIuLedger();
|
|
183
|
+
const a = makeIu("ctx-a", "iu-1", "user_turn");
|
|
184
|
+
const b = makeIu("ctx-b", "iu-1", "user_turn");
|
|
185
|
+
ledger.add(a);
|
|
186
|
+
ledger.add(b);
|
|
187
|
+
ledger.clear("ctx-a");
|
|
188
|
+
expect(ledger.get(a.id)).toBeUndefined();
|
|
189
|
+
expect(ledger.get(b.id)?.state).toBe("hypothesized");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("is a no-op for an unknown context without affecting others (failure path)", () => {
|
|
193
|
+
const ledger = new InMemoryIuLedger();
|
|
194
|
+
const b = makeIu("ctx-b", "iu-1", "user_turn");
|
|
195
|
+
ledger.add(b);
|
|
196
|
+
ledger.clear("ctx-missing");
|
|
197
|
+
expect(ledger.get(b.id)?.state).toBe("hypothesized");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("monotonic state machine", () => {
|
|
202
|
+
it("follows hypothesized → committed and hypothesized → revoked only", () => {
|
|
203
|
+
const ledger = new InMemoryIuLedger();
|
|
204
|
+
const committed = makeIu("ctx-a", "iu-commit", "user_turn");
|
|
205
|
+
const revoked = makeIu("ctx-a", "iu-revoke", "user_turn");
|
|
206
|
+
ledger.add(committed);
|
|
207
|
+
ledger.add(revoked);
|
|
208
|
+
ledger.commit(committed.id);
|
|
209
|
+
ledger.revoke(revoked.id);
|
|
210
|
+
expect(ledger.get(committed.id)?.state).toBe("committed");
|
|
211
|
+
expect(ledger.get(revoked.id)?.state).toBe("revoked");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("maxContexts bound", () => {
|
|
216
|
+
it("evicts the oldest context when a new context exceeds the cap (FIFO)", () => {
|
|
217
|
+
const ledger = new InMemoryIuLedger(() => {}, 3);
|
|
218
|
+
const a = makeIu("a", "iu-1", "user_turn");
|
|
219
|
+
const b = makeIu("b", "iu-1", "user_turn");
|
|
220
|
+
const c = makeIu("c", "iu-1", "user_turn");
|
|
221
|
+
const d = makeIu("d", "iu-1", "user_turn");
|
|
222
|
+
ledger.add(a);
|
|
223
|
+
ledger.add(b);
|
|
224
|
+
ledger.add(c);
|
|
225
|
+
ledger.add(d);
|
|
226
|
+
expect(ledger.get(a.id)).toBeUndefined();
|
|
227
|
+
expect(ledger.get(b.id)?.state).toBe("hypothesized");
|
|
228
|
+
expect(ledger.get(c.id)?.state).toBe("hypothesized");
|
|
229
|
+
expect(ledger.get(d.id)?.state).toBe("hypothesized");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not evict when adding another IU to an existing context", () => {
|
|
233
|
+
const ledger = new InMemoryIuLedger(() => {}, 3);
|
|
234
|
+
const a1 = makeIu("a", "iu-1", "user_turn");
|
|
235
|
+
const a2 = makeIu("a", "iu-2", "assistant_response");
|
|
236
|
+
const b = makeIu("b", "iu-1", "user_turn");
|
|
237
|
+
const c = makeIu("c", "iu-1", "user_turn");
|
|
238
|
+
ledger.add(a1);
|
|
239
|
+
ledger.add(b);
|
|
240
|
+
ledger.add(c);
|
|
241
|
+
ledger.add(a2);
|
|
242
|
+
expect(ledger.get(a1.id)?.state).toBe("hypothesized");
|
|
243
|
+
expect(ledger.get(a2.id)?.state).toBe("hypothesized");
|
|
244
|
+
expect(ledger.get(b.id)?.state).toBe("hypothesized");
|
|
245
|
+
expect(ledger.get(c.id)?.state).toBe("hypothesized");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("O(1) per-op (structural)", () => {
|
|
250
|
+
it("commit/get touch only the target ctx bucket regardless of total ctx count", () => {
|
|
251
|
+
// Map.get/set/delete are O(1); each hot-path op does at most one outer + one inner lookup.
|
|
252
|
+
const ledgerSmall = new InMemoryIuLedger();
|
|
253
|
+
const ledgerLarge = new InMemoryIuLedger();
|
|
254
|
+
const targetSmall = seedContexts(ledgerSmall, 10);
|
|
255
|
+
const targetLarge = seedContexts(ledgerLarge, 1000);
|
|
256
|
+
|
|
257
|
+
ledgerSmall.commit(targetSmall);
|
|
258
|
+
ledgerLarge.commit(targetLarge);
|
|
259
|
+
|
|
260
|
+
expect(ledgerSmall.get(targetSmall)?.state).toBe("committed");
|
|
261
|
+
expect(ledgerLarge.get(targetLarge)?.state).toBe("committed");
|
|
262
|
+
|
|
263
|
+
const timeOp = (ledger: InMemoryIuLedger, id: IncrementalUnitId) => {
|
|
264
|
+
const start = performance.now();
|
|
265
|
+
for (let i = 0; i < 5000; i++) {
|
|
266
|
+
ledger.get(id);
|
|
267
|
+
}
|
|
268
|
+
return performance.now() - start;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const tSmall = timeOp(ledgerSmall, targetSmall);
|
|
272
|
+
const tLarge = timeOp(ledgerLarge, targetLarge);
|
|
273
|
+
expect(tLarge).toBeLessThan(Math.max(tSmall * 20, 1));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -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,248 @@
|
|
|
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
|
+
provider: "p1",
|
|
127
|
+
model: "m1",
|
|
128
|
+
region: "r1",
|
|
129
|
+
cancelled: "false",
|
|
130
|
+
layer: "conversation",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const thinking = exporter.histograms.find((h) => h.name === "thinking_ms");
|
|
134
|
+
expect(thinking).toBeDefined();
|
|
135
|
+
expect(thinking!.valueMs).toBeGreaterThanOrEqual(0);
|
|
136
|
+
|
|
137
|
+
const agentSpeech = exporter.histograms.find((h) => h.name === "agent_speech_ms");
|
|
138
|
+
expect(agentSpeech).toBeDefined();
|
|
139
|
+
expect(agentSpeech!.valueMs).toBeGreaterThanOrEqual(0);
|
|
140
|
+
|
|
141
|
+
for (const histogram of exporter.histograms) {
|
|
142
|
+
expect(histogram.tags).not.toHaveProperty("sessionId");
|
|
143
|
+
expect(histogram.tags).not.toHaveProperty("speechId");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("tags interruption boundary and histograms as cancelled", async () => {
|
|
149
|
+
await withObserver(async ({ bus, exporter, boundaries }) => {
|
|
150
|
+
bus.push(Route.Critical, {
|
|
151
|
+
kind: "interrupt.detected",
|
|
152
|
+
contextId: SPEECH_ID,
|
|
153
|
+
timestampMs: 2000,
|
|
154
|
+
source: "vad",
|
|
155
|
+
} satisfies InterruptionDetectedPacket);
|
|
156
|
+
await drainBus();
|
|
157
|
+
|
|
158
|
+
const interruption = boundaries.find((b) => b.boundary === "interruption");
|
|
159
|
+
expect(interruption).toBeDefined();
|
|
160
|
+
expect(interruption!.cancelled).toBe(true);
|
|
161
|
+
|
|
162
|
+
const withCancelled = exporter.histograms.filter((h) => h.tags.cancelled === "true");
|
|
163
|
+
for (const h of withCancelled) {
|
|
164
|
+
expect(h.tags).toMatchObject({
|
|
165
|
+
sessionId: "sess-1",
|
|
166
|
+
speechId: SPEECH_ID,
|
|
167
|
+
cancelled: "true",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("emits agent_started_speaking only once per speechId", async () => {
|
|
174
|
+
await withObserver(async ({ bus, boundaries }) => {
|
|
175
|
+
bus.push(Route.Main, {
|
|
176
|
+
kind: "tts.audio",
|
|
177
|
+
contextId: SPEECH_ID,
|
|
178
|
+
timestampMs: 100,
|
|
179
|
+
audio: new Uint8Array(4),
|
|
180
|
+
sampleRateHz: 16000,
|
|
181
|
+
} satisfies TextToSpeechAudioPacket);
|
|
182
|
+
bus.push(Route.Main, {
|
|
183
|
+
kind: "tts.audio",
|
|
184
|
+
contextId: SPEECH_ID,
|
|
185
|
+
timestampMs: 200,
|
|
186
|
+
audio: new Uint8Array(4),
|
|
187
|
+
sampleRateHz: 16000,
|
|
188
|
+
} satisfies TextToSpeechAudioPacket);
|
|
189
|
+
await drainBus();
|
|
190
|
+
|
|
191
|
+
const started = boundaries.filter((b) => b.boundary === "agent_started_speaking");
|
|
192
|
+
expect(started).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("uses provider/model/region dimensions from STT and TTS packets when present", async () => {
|
|
197
|
+
await withObserver(async ({ bus, exporter, boundaries }) => {
|
|
198
|
+
bus.push(Route.Main, {
|
|
199
|
+
kind: "vad.speech_ended",
|
|
200
|
+
contextId: SPEECH_ID,
|
|
201
|
+
timestampMs: 1100,
|
|
202
|
+
} satisfies VadSpeechEndedPacket);
|
|
203
|
+
bus.push(Route.Main, {
|
|
204
|
+
kind: "eos.turn_complete",
|
|
205
|
+
contextId: SPEECH_ID,
|
|
206
|
+
timestampMs: 1200,
|
|
207
|
+
text: "hello",
|
|
208
|
+
transcripts: [
|
|
209
|
+
{
|
|
210
|
+
kind: "stt.result",
|
|
211
|
+
contextId: SPEECH_ID,
|
|
212
|
+
timestampMs: 1190,
|
|
213
|
+
text: "hello",
|
|
214
|
+
confidence: 0.9,
|
|
215
|
+
provider: { name: "deepgram", model: "nova-3", region: "global" },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
} satisfies EndOfSpeechPacket);
|
|
219
|
+
bus.push(Route.Main, {
|
|
220
|
+
kind: "tts.audio",
|
|
221
|
+
contextId: SPEECH_ID,
|
|
222
|
+
timestampMs: 1300,
|
|
223
|
+
audio: new Uint8Array(320),
|
|
224
|
+
sampleRateHz: 16000,
|
|
225
|
+
provider: { name: "cartesia", model: "sonic-3", region: "global", cancelled: false },
|
|
226
|
+
} satisfies TextToSpeechAudioPacket);
|
|
227
|
+
await drainBus();
|
|
228
|
+
|
|
229
|
+
const started = boundaries.find((b) => b.boundary === "agent_started_speaking");
|
|
230
|
+
expect(started).toMatchObject({
|
|
231
|
+
provider: "cartesia",
|
|
232
|
+
model: "sonic-3",
|
|
233
|
+
region: "global",
|
|
234
|
+
});
|
|
235
|
+
const thinking = boundaries.find((b) => b.boundary === "agent_thinking");
|
|
236
|
+
expect(thinking).toMatchObject({
|
|
237
|
+
provider: "deepgram",
|
|
238
|
+
model: "nova-3",
|
|
239
|
+
region: "global",
|
|
240
|
+
});
|
|
241
|
+
expect(exporter.histograms.find((h) => h.name === "v2v_ms")?.tags).toMatchObject({
|
|
242
|
+
provider: "cartesia",
|
|
243
|
+
model: "sonic-3",
|
|
244
|
+
region: "global",
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
InMemoryMetricsExporter,
|
|
7
|
+
localizeTurn,
|
|
8
|
+
monotonicNowMs,
|
|
9
|
+
noopMetricsExporter,
|
|
10
|
+
reconstructTurnTimeline,
|
|
11
|
+
} from "./observability.js";
|
|
12
|
+
import type { TurnBoundaryEventPacket } from "./packets.js";
|
|
13
|
+
|
|
14
|
+
describe("monotonicNowMs", () => {
|
|
15
|
+
it("returns increasing positive numbers", () => {
|
|
16
|
+
const first = monotonicNowMs();
|
|
17
|
+
const second = monotonicNowMs();
|
|
18
|
+
expect(first).toBeGreaterThan(0);
|
|
19
|
+
expect(second).toBeGreaterThanOrEqual(first);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("InMemoryMetricsExporter", () => {
|
|
24
|
+
it("records histogram name, value, and tags", () => {
|
|
25
|
+
const exporter = new InMemoryMetricsExporter();
|
|
26
|
+
exporter.observeHistogram("turn.latency_ms", 42, { sessionId: "s1", boundary: "agent_audio_done" });
|
|
27
|
+
expect(exporter.histograms).toEqual([
|
|
28
|
+
{
|
|
29
|
+
name: "turn.latency_ms",
|
|
30
|
+
valueMs: 42,
|
|
31
|
+
tags: { sessionId: "s1", boundary: "agent_audio_done" },
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("records non-negative span duration on end", () => {
|
|
37
|
+
vi.useFakeTimers();
|
|
38
|
+
const exporter = new InMemoryMetricsExporter();
|
|
39
|
+
const handle = exporter.startSpan("turn.process", { sessionId: "s1" });
|
|
40
|
+
vi.advanceTimersByTime(10);
|
|
41
|
+
handle.end();
|
|
42
|
+
expect(exporter.spans).toHaveLength(1);
|
|
43
|
+
expect(exporter.spans[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
|
44
|
+
vi.useRealTimers();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("noopMetricsExporter", () => {
|
|
49
|
+
it("accepts histogram and span calls without throwing", () => {
|
|
50
|
+
noopMetricsExporter.observeHistogram("ignored", 0, {});
|
|
51
|
+
noopMetricsExporter.startSpan("ignored", {}).end();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("turn localization", () => {
|
|
56
|
+
it("prioritizes infrastructure, keeps conversation-only failures separate, and reports clean turns", () => {
|
|
57
|
+
expect(localizeTurn({ infrastructureBreached: true, conversationFlagged: true })).toBe("infrastructure");
|
|
58
|
+
expect(localizeTurn({ infrastructureBreached: false, conversationFlagged: true })).toBe("conversation");
|
|
59
|
+
expect(localizeTurn({ infrastructureBreached: false, conversationFlagged: false })).toBe("none");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("reconstructTurnTimeline", () => {
|
|
64
|
+
function ev(sessionId: string, speechId: string, boundary: TurnBoundaryEventPacket["boundary"], monotonicMs: number, cancelled = false): TurnBoundaryEventPacket {
|
|
65
|
+
return { kind: "obs.turn_boundary", contextId: speechId, timestampMs: 0, boundary, sessionId, speechId, monotonicMs, cancelled };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it("orders one session's boundaries by monotonic time with inter-boundary deltas", () => {
|
|
69
|
+
const events = [
|
|
70
|
+
ev("sess-A", "turn-1", "agent_started_speaking", 300),
|
|
71
|
+
ev("sess-A", "turn-1", "user_started_speaking", 100),
|
|
72
|
+
ev("sess-B", "turn-9", "user_started_speaking", 150), // other session — excluded
|
|
73
|
+
ev("sess-A", "turn-1", "user_stopped_speaking", 200),
|
|
74
|
+
];
|
|
75
|
+
const timeline = reconstructTurnTimeline(events, "sess-A");
|
|
76
|
+
expect(timeline.map((s) => s.boundary)).toEqual([
|
|
77
|
+
"user_started_speaking",
|
|
78
|
+
"user_stopped_speaking",
|
|
79
|
+
"agent_started_speaking",
|
|
80
|
+
]);
|
|
81
|
+
expect(timeline.map((s) => s.sincePrevMs)).toEqual([0, 100, 100]);
|
|
82
|
+
expect(timeline.every((s) => s.speechId === "turn-1")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("filters by speechId when provided and surfaces the cancelled flag", () => {
|
|
86
|
+
const events = [
|
|
87
|
+
ev("s", "t1", "user_started_speaking", 10),
|
|
88
|
+
ev("s", "t2", "interruption", 20, true),
|
|
89
|
+
];
|
|
90
|
+
const t2 = reconstructTurnTimeline(events, "s", "t2");
|
|
91
|
+
expect(t2).toHaveLength(1);
|
|
92
|
+
expect(t2[0]!.cancelled).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|