@kuralle-syrinx/core 4.2.0 → 4.4.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/package.json +2 -2
- package/src/audio/alaw.ts +56 -0
- package/src/audio/g722.ts +329 -0
- package/src/audio/index.ts +12 -0
- package/src/audio/loudness.ts +87 -0
- package/src/idle-timeout.ts +1 -0
- package/src/index.ts +65 -3
- package/src/interaction-coordinator.ts +17 -2
- package/src/interaction-policy.ts +12 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +124 -3
- package/src/packets.ts +139 -1
- package/src/plugin-contract.ts +41 -0
- package/src/policies/rule-based.ts +17 -2
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +34 -2
- package/src/voice-agent-session-util.ts +18 -0
- package/src/voice-agent-session.ts +469 -36
- package/src/voice-text.ts +69 -0
- package/src/audio/audio.test.ts +0 -285
- package/src/audio-envelope.test.ts +0 -167
- package/src/confidence-to-wait.test.ts +0 -21
- package/src/error-handler.test.ts +0 -56
- package/src/hedge-throwing-backend.test.ts +0 -46
- package/src/interaction-coordinator.test.ts +0 -425
- package/src/iu-ledger.test.ts +0 -276
- package/src/latency-filler.test.ts +0 -62
- package/src/observability-observer.test.ts +0 -245
- package/src/observability.test.ts +0 -85
- package/src/packet-factories.test.ts +0 -53
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/policies/defer.test.ts +0 -86
- package/src/policies/rule-based.test.ts +0 -269
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner-hedge.test.ts +0 -361
- package/src/reasoner-route.test.ts +0 -248
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- package/src/route-throwing-spec.test.ts +0 -44
- package/src/tts-playout-clock.test.ts +0 -125
- package/src/turn-arbiter.characterization.test.ts +0 -477
- package/src/turn-arbiter.test.ts +0 -567
- package/src/voice-agent-session.test.ts +0 -3316
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { PipelineBusImpl } from "./pipeline-bus.js";
|
|
6
|
-
import type { ConversationMetricPacket } from "./packets.js";
|
|
7
|
-
import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
|
|
8
|
-
import { RoutingReasoner } from "./reasoner-route.js";
|
|
9
|
-
|
|
10
|
-
class ControllableReasoner implements Reasoner {
|
|
11
|
-
streamInvoked = false;
|
|
12
|
-
streamCallCount = 0;
|
|
13
|
-
capturedSignal: AbortSignal | undefined;
|
|
14
|
-
private deliver: ((part: ReasoningPart) => void) | null = null;
|
|
15
|
-
|
|
16
|
-
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
17
|
-
this.streamInvoked = true;
|
|
18
|
-
this.streamCallCount += 1;
|
|
19
|
-
this.capturedSignal = turn.signal;
|
|
20
|
-
const queue: ReasoningPart[] = [];
|
|
21
|
-
let wake: (() => void) | null = null;
|
|
22
|
-
|
|
23
|
-
this.deliver = (part: ReasoningPart) => {
|
|
24
|
-
queue.push(part);
|
|
25
|
-
wake?.();
|
|
26
|
-
wake = null;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
async function* generator(): AsyncGenerator<ReasoningPart> {
|
|
30
|
-
while (!turn.signal.aborted) {
|
|
31
|
-
if (queue.length === 0) {
|
|
32
|
-
await new Promise<void>((resolve) => {
|
|
33
|
-
if (turn.signal.aborted) {
|
|
34
|
-
resolve();
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
wake = resolve;
|
|
38
|
-
turn.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
if (turn.signal.aborted) return;
|
|
42
|
-
const part = queue.shift();
|
|
43
|
-
if (part === undefined) return;
|
|
44
|
-
yield part;
|
|
45
|
-
if (part.type === "error" || part.type === "suspended" || part.type === "finish") return;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return generator();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
emit(part: ReasoningPart): void {
|
|
53
|
-
this.deliver?.(part);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function baseTurn(signal = new AbortController().signal): ReasonerTurn {
|
|
58
|
-
return { userText: "hi", messages: [{ role: "user", content: "hi" }], signal };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function collect(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
62
|
-
const parts: ReasoningPart[] = [];
|
|
63
|
-
for await (const part of reasoner.stream(turn)) {
|
|
64
|
-
parts.push(part);
|
|
65
|
-
}
|
|
66
|
-
return parts;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function scriptedReasoner(
|
|
70
|
-
parts: readonly ReasoningPart[],
|
|
71
|
-
hook?: (turn: ReasonerTurn) => void,
|
|
72
|
-
): Reasoner {
|
|
73
|
-
return {
|
|
74
|
-
stream(turn) {
|
|
75
|
-
hook?.(turn);
|
|
76
|
-
return (async function*() {
|
|
77
|
-
for (const part of parts) {
|
|
78
|
-
if (turn.signal.aborted) return;
|
|
79
|
-
yield part;
|
|
80
|
-
}
|
|
81
|
-
})();
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const fastParts: ReasoningPart[] = [
|
|
87
|
-
{ type: "text-delta", text: "fast" },
|
|
88
|
-
{ type: "finish", reason: "stop", text: "fast" },
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
const deepParts: ReasoningPart[] = [
|
|
92
|
-
{ type: "text-delta", text: "deep" },
|
|
93
|
-
{ type: "finish", reason: "stop", text: "deep" },
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
describe("RoutingReasoner", () => {
|
|
97
|
-
it("classify routing — fast and deep without speculation", async () => {
|
|
98
|
-
let fastInvoked = false;
|
|
99
|
-
let deepInvoked = false;
|
|
100
|
-
|
|
101
|
-
const routes = [
|
|
102
|
-
{
|
|
103
|
-
id: "fast",
|
|
104
|
-
reasoner: scriptedReasoner(fastParts, () => {
|
|
105
|
-
fastInvoked = true;
|
|
106
|
-
}),
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
id: "deep",
|
|
110
|
-
reasoner: scriptedReasoner(deepParts, () => {
|
|
111
|
-
deepInvoked = true;
|
|
112
|
-
}),
|
|
113
|
-
},
|
|
114
|
-
];
|
|
115
|
-
|
|
116
|
-
const fastRouter = new RoutingReasoner({
|
|
117
|
-
routes,
|
|
118
|
-
classify: () => "fast",
|
|
119
|
-
});
|
|
120
|
-
const fastOutput = await collect(fastRouter, baseTurn());
|
|
121
|
-
expect(fastOutput).toEqual(fastParts);
|
|
122
|
-
expect(fastInvoked).toBe(true);
|
|
123
|
-
expect(deepInvoked).toBe(false);
|
|
124
|
-
|
|
125
|
-
fastInvoked = false;
|
|
126
|
-
deepInvoked = false;
|
|
127
|
-
|
|
128
|
-
const deepRouter = new RoutingReasoner({
|
|
129
|
-
routes,
|
|
130
|
-
classify: () => "deep",
|
|
131
|
-
});
|
|
132
|
-
const deepOutput = await collect(deepRouter, baseTurn());
|
|
133
|
-
expect(deepOutput).toEqual(deepParts);
|
|
134
|
-
expect(deepInvoked).toBe(true);
|
|
135
|
-
expect(fastInvoked).toBe(false);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("speculation kept (agree)", async () => {
|
|
139
|
-
const fast = new ControllableReasoner();
|
|
140
|
-
const deep = new ControllableReasoner();
|
|
141
|
-
const bus = new PipelineBusImpl();
|
|
142
|
-
const started = bus.start();
|
|
143
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
144
|
-
bus.on("metric.conversation", (pkt) => {
|
|
145
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const router = new RoutingReasoner({
|
|
149
|
-
routes: [
|
|
150
|
-
{ id: "fast", reasoner: fast },
|
|
151
|
-
{ id: "deep", reasoner: deep },
|
|
152
|
-
],
|
|
153
|
-
classify: () => "fast",
|
|
154
|
-
speculateRouteId: "fast",
|
|
155
|
-
bus,
|
|
156
|
-
contextId: "ctx-1",
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const streamPromise = collect(router, baseTurn());
|
|
160
|
-
fast.emit({ type: "text-delta", text: "fast" });
|
|
161
|
-
fast.emit({ type: "finish", reason: "stop", text: "fast" });
|
|
162
|
-
const parts = await streamPromise;
|
|
163
|
-
|
|
164
|
-
expect(fast.streamCallCount).toBe(1);
|
|
165
|
-
expect(deep.streamInvoked).toBe(false);
|
|
166
|
-
expect(parts).toEqual(fastParts);
|
|
167
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "route.selected", value: "fast" }));
|
|
168
|
-
expect(metrics.some((m) => m.name === "route.mispredict")).toBe(false);
|
|
169
|
-
|
|
170
|
-
bus.stop();
|
|
171
|
-
await started;
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("speculation discarded (disagree) — no forwarded part from mispredicted route", async () => {
|
|
175
|
-
const fast = new ControllableReasoner();
|
|
176
|
-
let deepInvoked = false;
|
|
177
|
-
let resolveClassify: ((id: string) => void) | undefined;
|
|
178
|
-
const classifyGate = new Promise<string>((resolve) => {
|
|
179
|
-
resolveClassify = resolve;
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const bus = new PipelineBusImpl();
|
|
183
|
-
const started = bus.start();
|
|
184
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
185
|
-
bus.on("metric.conversation", (pkt) => {
|
|
186
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const router = new RoutingReasoner({
|
|
190
|
-
routes: [
|
|
191
|
-
{ id: "fast", reasoner: fast },
|
|
192
|
-
{
|
|
193
|
-
id: "deep",
|
|
194
|
-
reasoner: scriptedReasoner(deepParts, () => {
|
|
195
|
-
deepInvoked = true;
|
|
196
|
-
}),
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
classify: () => classifyGate,
|
|
200
|
-
speculateRouteId: "fast",
|
|
201
|
-
bus,
|
|
202
|
-
contextId: "ctx-2",
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
const streamPromise = collect(router, baseTurn());
|
|
206
|
-
await Promise.resolve();
|
|
207
|
-
fast.emit({ type: "text-delta", text: "must-not-forward" });
|
|
208
|
-
resolveClassify!("deep");
|
|
209
|
-
const parts = await streamPromise;
|
|
210
|
-
|
|
211
|
-
expect(fast.capturedSignal?.aborted).toBe(true);
|
|
212
|
-
expect(fast.streamCallCount).toBe(1);
|
|
213
|
-
expect(deepInvoked).toBe(true);
|
|
214
|
-
expect(parts).toEqual(deepParts);
|
|
215
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "route.mispredict", value: "1" }));
|
|
216
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "route.selected", value: "deep" }));
|
|
217
|
-
|
|
218
|
-
bus.stop();
|
|
219
|
-
await started;
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("R8 passthrough — single route equals direct stream", async () => {
|
|
223
|
-
const soleParts: ReasoningPart[] = [
|
|
224
|
-
{ type: "text-delta", text: "only" },
|
|
225
|
-
{ type: "finish", reason: "stop", text: "only" },
|
|
226
|
-
];
|
|
227
|
-
const sole = scriptedReasoner(soleParts);
|
|
228
|
-
const direct = await collect(sole, baseTurn());
|
|
229
|
-
|
|
230
|
-
const router = new RoutingReasoner({
|
|
231
|
-
routes: [{ id: "only", reasoner: sole }],
|
|
232
|
-
classify: () => "only",
|
|
233
|
-
});
|
|
234
|
-
const routed = await collect(router, baseTurn());
|
|
235
|
-
|
|
236
|
-
expect(routed).toEqual(direct);
|
|
237
|
-
expect(routed).toEqual(soleParts);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("unknown id — throws clear error", async () => {
|
|
241
|
-
const router = new RoutingReasoner({
|
|
242
|
-
routes: [{ id: "fast", reasoner: scriptedReasoner(fastParts) }],
|
|
243
|
-
classify: () => "missing",
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
await expect(collect(router, baseTurn())).rejects.toThrow('RoutingReasoner: unknown route id "missing"');
|
|
247
|
-
});
|
|
248
|
-
});
|
package/src/reasoner.test.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
//
|
|
3
|
-
// Compile-guard: pins the ReasoningPart union shape without runtime consumers.
|
|
4
|
-
|
|
5
|
-
import { describe, expect, it } from "vitest";
|
|
6
|
-
|
|
7
|
-
import type { Reasoner, ReasonerMessage, ReasonerTurn, ReasoningPart } from "./reasoner.js";
|
|
8
|
-
|
|
9
|
-
describe("Reasoner seam types", () => {
|
|
10
|
-
it("reasoner_union_compile_guard", async () => {
|
|
11
|
-
const userMsg = { role: "user", content: "hi" } satisfies ReasonerMessage;
|
|
12
|
-
|
|
13
|
-
const turn: ReasonerTurn = {
|
|
14
|
-
userText: "hi",
|
|
15
|
-
messages: [userMsg],
|
|
16
|
-
signal: new AbortController().signal,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const textDeltaPart = { type: "text-delta", text: "hello" } satisfies ReasoningPart;
|
|
20
|
-
const toolCallPart = {
|
|
21
|
-
type: "tool-call",
|
|
22
|
-
toolId: "tool-1",
|
|
23
|
-
toolName: "doThing",
|
|
24
|
-
args: { input: 123 },
|
|
25
|
-
} satisfies ReasoningPart;
|
|
26
|
-
const toolResultPart = {
|
|
27
|
-
type: "tool-result",
|
|
28
|
-
toolId: "tool-1",
|
|
29
|
-
toolName: "doThing",
|
|
30
|
-
result: "ok",
|
|
31
|
-
} satisfies ReasoningPart;
|
|
32
|
-
const suspendedPart = {
|
|
33
|
-
type: "suspended",
|
|
34
|
-
runId: "run-1",
|
|
35
|
-
toolId: "tool-1",
|
|
36
|
-
prompt: "Pause for human-in-the-loop.",
|
|
37
|
-
payload: { step: 3 },
|
|
38
|
-
} satisfies ReasoningPart;
|
|
39
|
-
const errorPart = {
|
|
40
|
-
type: "error",
|
|
41
|
-
cause: new Error("backend exploded"),
|
|
42
|
-
recoverable: true,
|
|
43
|
-
} satisfies ReasoningPart;
|
|
44
|
-
const finishPart = { type: "finish", reason: "stop", text: "done" } satisfies ReasoningPart;
|
|
45
|
-
|
|
46
|
-
const emptyStream = async function*(): AsyncIterable<ReasoningPart> {
|
|
47
|
-
// Intentionally empty: this is purely a type-level compile guard.
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const reasoner: Reasoner = {
|
|
51
|
-
stream: (_turn: ReasonerTurn): AsyncIterable<ReasoningPart> => emptyStream(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const collected: ReasoningPart[] = [];
|
|
55
|
-
for await (const part of reasoner.stream(turn)) {
|
|
56
|
-
collected.push(part);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
expect(collected).toEqual([]);
|
|
60
|
-
expect(textDeltaPart).toBeDefined();
|
|
61
|
-
expect(toolCallPart).toBeDefined();
|
|
62
|
-
expect(toolResultPart).toBeDefined();
|
|
63
|
-
expect(suspendedPart).toBeDefined();
|
|
64
|
-
expect(errorPart).toBeDefined();
|
|
65
|
-
expect(finishPart).toBeDefined();
|
|
66
|
-
expect(reasoner).toBeDefined();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
package/src/retry.test.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { DEFAULT_RETRY_CONFIG, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG, VOICE_PROVIDER_RETRY_CONFIG, readProviderRetryConfig, readRetryConfig, retryDelayMs, retryDelayWithJitterMs, waitForRetryDelay } from "./retry.js";
|
|
6
|
-
|
|
7
|
-
describe("retry helpers", () => {
|
|
8
|
-
it("reads bounded retry config from plugin config", () => {
|
|
9
|
-
expect(
|
|
10
|
-
readRetryConfig({
|
|
11
|
-
retry_max_attempts: 5,
|
|
12
|
-
retry_base_delay_ms: 100,
|
|
13
|
-
retry_max_delay_ms: 900,
|
|
14
|
-
}),
|
|
15
|
-
).toEqual({
|
|
16
|
-
maxAttempts: 5,
|
|
17
|
-
baseDelayMs: 100,
|
|
18
|
-
maxDelayMs: 900,
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("backs off exponentially up to the configured cap", () => {
|
|
23
|
-
const config = {
|
|
24
|
-
maxAttempts: 5,
|
|
25
|
-
baseDelayMs: 100,
|
|
26
|
-
maxDelayMs: 250,
|
|
27
|
-
};
|
|
28
|
-
expect(retryDelayMs(1, config)).toBe(100);
|
|
29
|
-
expect(retryDelayMs(2, config)).toBe(200);
|
|
30
|
-
expect(retryDelayMs(3, config)).toBe(250);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("applies equal jitter: half the deterministic delay plus a random half", () => {
|
|
34
|
-
const config = { maxAttempts: 5, baseDelayMs: 100, maxDelayMs: 1000 };
|
|
35
|
-
// attempt 2 deterministic = 200 → equal-jitter range [100, 200].
|
|
36
|
-
expect(retryDelayWithJitterMs(2, config, () => 0)).toBe(100);
|
|
37
|
-
expect(retryDelayWithJitterMs(2, config, () => 1)).toBe(200);
|
|
38
|
-
expect(retryDelayWithJitterMs(2, config, () => 0.5)).toBe(150);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("can be aborted while waiting", async () => {
|
|
42
|
-
vi.useFakeTimers();
|
|
43
|
-
const controller = new AbortController();
|
|
44
|
-
const wait = waitForRetryDelay(
|
|
45
|
-
1,
|
|
46
|
-
{
|
|
47
|
-
maxAttempts: 3,
|
|
48
|
-
baseDelayMs: 1000,
|
|
49
|
-
maxDelayMs: 1000,
|
|
50
|
-
},
|
|
51
|
-
controller.signal,
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
controller.abort();
|
|
55
|
-
await expect(wait).rejects.toMatchObject({ name: "AbortError" });
|
|
56
|
-
vi.useRealTimers();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("voice-provider profile keeps fast first reconnect but raises the backoff cap", () => {
|
|
60
|
-
// Fast first reconnect (no multi-second floor → no dead air on transient blips)...
|
|
61
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(250);
|
|
62
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(DEFAULT_RETRY_CONFIG.baseDelayMs);
|
|
63
|
-
// ...but a patient cap for persistent provider failure (vs the 2s default).
|
|
64
|
-
expect(retryDelayMs(10, VOICE_PROVIDER_RETRY_CONFIG)).toBe(10_000);
|
|
65
|
-
expect(VOICE_PROVIDER_RETRY_CONFIG.maxAttempts).toBeGreaterThan(DEFAULT_RETRY_CONFIG.maxAttempts);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("readProviderRetryConfig defaults to the voice-provider profile but honors overrides", () => {
|
|
69
|
-
expect(readProviderRetryConfig({})).toEqual(VOICE_PROVIDER_RETRY_CONFIG);
|
|
70
|
-
expect(readProviderRetryConfig({ retry_max_delay_ms: 3000 }).maxDelayMs).toBe(3000);
|
|
71
|
-
// The plain default profile is unchanged (intra-turn low-latency retries).
|
|
72
|
-
expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("offers a provider-outage profile with a 4s floor and 10s cap", () => {
|
|
76
|
-
expect(retryDelayMs(1, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(4_000);
|
|
77
|
-
expect(retryDelayMs(2, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(8_000);
|
|
78
|
-
expect(retryDelayMs(3, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(10_000);
|
|
79
|
-
expect(readProviderRetryConfig({ retry_profile: "provider_outage" })).toEqual(VOICE_PROVIDER_OUTAGE_RETRY_CONFIG);
|
|
80
|
-
expect(readProviderRetryConfig({ retry_profile: "provider_outage", retry_base_delay_ms: 5000 }).baseDelayMs).toBe(5000);
|
|
81
|
-
expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Regression guard (RL-WBS-2): a mispredicted speculative route whose next() rejects must NOT leak an
|
|
3
|
-
// unhandled rejection (the abandoned specNext must be caught); and a throwing route must surface, not hang.
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { RoutingReasoner } from "./reasoner-route.js";
|
|
6
|
-
import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
|
|
7
|
-
|
|
8
|
-
class ThrowingReasoner implements Reasoner {
|
|
9
|
-
async *stream(_t: ReasonerTurn): AsyncGenerator<ReasoningPart> { throw new Error("spec boom"); }
|
|
10
|
-
}
|
|
11
|
-
class GoodReasoner implements Reasoner {
|
|
12
|
-
constructor(private readonly text: string) {}
|
|
13
|
-
async *stream(_t: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
14
|
-
yield { type: "text-delta", text: this.text };
|
|
15
|
-
yield { type: "finish", reason: "stop", text: this.text };
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function turn(): ReasonerTurn { return { userText: "hi", messages: [], signal: new AbortController().signal }; }
|
|
19
|
-
|
|
20
|
-
describe("route reject repro", () => {
|
|
21
|
-
it("mispredicted speculative route that throws must not cause an unhandled rejection", async () => {
|
|
22
|
-
const unhandled: unknown[] = [];
|
|
23
|
-
const onUnhandled = (e: unknown) => unhandled.push(e);
|
|
24
|
-
process.on("unhandledRejection", onUnhandled);
|
|
25
|
-
try {
|
|
26
|
-
const router = new RoutingReasoner({
|
|
27
|
-
routes: [
|
|
28
|
-
{ id: "fast", reasoner: new ThrowingReasoner() }, // speculated → will be abandoned
|
|
29
|
-
{ id: "deep", reasoner: new GoodReasoner("deep ok") }, // classify picks this (disagree)
|
|
30
|
-
],
|
|
31
|
-
classify: () => "deep",
|
|
32
|
-
speculateRouteId: "fast",
|
|
33
|
-
});
|
|
34
|
-
const parts: string[] = [];
|
|
35
|
-
for await (const p of router.stream(turn())) if (p.type === "text-delta") parts.push(p.text);
|
|
36
|
-
expect(parts).toEqual(["deep ok"]); // no part from the discarded spec route
|
|
37
|
-
// let any dangling rejection surface
|
|
38
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
39
|
-
expect(unhandled, `unhandled rejections: ${unhandled.map(String).join("; ")}`).toEqual([]);
|
|
40
|
-
} finally {
|
|
41
|
-
process.off("unhandledRejection", onUnhandled);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
5
|
-
|
|
6
|
-
describe("TtsPlayoutClock", () => {
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.useFakeTimers();
|
|
9
|
-
vi.setSystemTime(0);
|
|
10
|
-
});
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
vi.useRealTimers();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("marks a context active on the first audio chunk", () => {
|
|
16
|
-
const clock = new TtsPlayoutClock();
|
|
17
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
18
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
19
|
-
expect(clock.isActive("c1")).toBe(true);
|
|
20
|
-
expect(clock.latestActive()).toBe("c1");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("keeps a context interruptible until its playout estimate elapses, then releases", () => {
|
|
24
|
-
const clock = new TtsPlayoutClock();
|
|
25
|
-
clock.noteAudio("c1", 200, Date.now()); // playout ends at t=200
|
|
26
|
-
clock.scheduleRelease("c1", Date.now()); // 200ms remaining
|
|
27
|
-
expect(clock.isActive("c1")).toBe(true);
|
|
28
|
-
vi.advanceTimersByTime(199);
|
|
29
|
-
expect(clock.isActive("c1")).toBe(true);
|
|
30
|
-
vi.advanceTimersByTime(1);
|
|
31
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("releases immediately when the playout estimate has already passed", () => {
|
|
35
|
-
const clock = new TtsPlayoutClock();
|
|
36
|
-
clock.noteAudio("c1", 50, Date.now());
|
|
37
|
-
vi.setSystemTime(100); // well past the 50ms estimate
|
|
38
|
-
clock.scheduleRelease("c1", Date.now());
|
|
39
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("lets real transport playout override the estimate: the timer must not pre-empt", () => {
|
|
43
|
-
const clock = new TtsPlayoutClock();
|
|
44
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
45
|
-
clock.scheduleRelease("c1", Date.now()); // estimate release at t=100
|
|
46
|
-
clock.noteProgress("c1", false); // transport now authoritative, not yet complete
|
|
47
|
-
vi.advanceTimersByTime(500); // estimate timer fires but must defer
|
|
48
|
-
expect(clock.isActive("c1")).toBe(true);
|
|
49
|
-
clock.noteProgress("c1", true); // transport says done
|
|
50
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("advances the playout cursor across chunks instead of resetting it", () => {
|
|
54
|
-
const clock = new TtsPlayoutClock();
|
|
55
|
-
clock.noteAudio("c1", 100, Date.now()); // ends at 100
|
|
56
|
-
clock.noteAudio("c1", 100, Date.now()); // still t=0, extends to 200
|
|
57
|
-
clock.scheduleRelease("c1", Date.now());
|
|
58
|
-
vi.advanceTimersByTime(150);
|
|
59
|
-
expect(clock.isActive("c1")).toBe(true); // would have released at 100 if reset
|
|
60
|
-
vi.advanceTimersByTime(50);
|
|
61
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("reports the most-recently-added active context as latest", () => {
|
|
65
|
-
const clock = new TtsPlayoutClock();
|
|
66
|
-
clock.noteAudio("a", 10, Date.now());
|
|
67
|
-
clock.noteAudio("b", 10, Date.now());
|
|
68
|
-
expect(clock.latestActive()).toBe("b");
|
|
69
|
-
clock.release("b");
|
|
70
|
-
expect(clock.latestActive()).toBe("a");
|
|
71
|
-
clock.release("a");
|
|
72
|
-
expect(clock.latestActive()).toBe("");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("records playout position and returns it via positionMs", () => {
|
|
76
|
-
const clock = new TtsPlayoutClock();
|
|
77
|
-
expect(clock.positionMs("c1")).toBeUndefined();
|
|
78
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
79
|
-
clock.noteProgress("c1", false, 42);
|
|
80
|
-
expect(clock.positionMs("c1")).toBe(42);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("keeps the latest playout position across multiple progress updates", () => {
|
|
84
|
-
const clock = new TtsPlayoutClock();
|
|
85
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
86
|
-
clock.noteProgress("c1", false, 10);
|
|
87
|
-
clock.noteProgress("c1", false, 55);
|
|
88
|
-
expect(clock.positionMs("c1")).toBe(55);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("drops recorded position on release and clear", () => {
|
|
92
|
-
const clock = new TtsPlayoutClock();
|
|
93
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
94
|
-
clock.noteProgress("c1", false, 30);
|
|
95
|
-
clock.release("c1");
|
|
96
|
-
expect(clock.positionMs("c1")).toBeUndefined();
|
|
97
|
-
|
|
98
|
-
clock.noteAudio("c2", 100, Date.now());
|
|
99
|
-
clock.noteProgress("c2", false, 20);
|
|
100
|
-
clock.clear();
|
|
101
|
-
expect(clock.positionMs("c2")).toBeUndefined();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("noteProgress without position still releases on complete", () => {
|
|
105
|
-
const clock = new TtsPlayoutClock();
|
|
106
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
107
|
-
clock.noteProgress("c1", false);
|
|
108
|
-
expect(clock.positionMs("c1")).toBeUndefined();
|
|
109
|
-
expect(clock.isActive("c1")).toBe(true);
|
|
110
|
-
clock.noteProgress("c1", true);
|
|
111
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
112
|
-
expect(clock.positionMs("c1")).toBeUndefined();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("clear() drops all state and cancels pending release timers", () => {
|
|
116
|
-
const clock = new TtsPlayoutClock();
|
|
117
|
-
clock.noteAudio("c1", 100, Date.now());
|
|
118
|
-
clock.scheduleRelease("c1", Date.now());
|
|
119
|
-
clock.clear();
|
|
120
|
-
expect(clock.isActive("c1")).toBe(false);
|
|
121
|
-
// A leaked timer would throw "release of cleared context" — assert none fire.
|
|
122
|
-
expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
|
|
123
|
-
expect(clock.latestActive()).toBe("");
|
|
124
|
-
});
|
|
125
|
-
});
|