@kuralle-syrinx/core 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +41 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +35 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +557 -2
- package/src/voice-agent-session.ts +308 -24
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { Route, type PipelineBus } from "./pipeline-bus.js";
|
|
4
|
+
import * as make from "./packet-factories.js";
|
|
5
|
+
import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
|
|
6
|
+
|
|
7
|
+
export interface ReasonerRoute {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly reasoner: Reasoner;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RoutingReasonerOptions {
|
|
13
|
+
readonly routes: readonly ReasonerRoute[];
|
|
14
|
+
readonly classify: (turn: ReasonerTurn) => string | Promise<string>;
|
|
15
|
+
readonly speculateRouteId?: string;
|
|
16
|
+
readonly bus?: PipelineBus;
|
|
17
|
+
readonly contextId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class RoutingReasoner implements Reasoner {
|
|
21
|
+
constructor(private readonly opts: RoutingReasonerOptions) {}
|
|
22
|
+
|
|
23
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
24
|
+
return this.doStream(turn);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async *doStream(turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
28
|
+
if (turn.signal.aborted) return;
|
|
29
|
+
|
|
30
|
+
if (this.opts.speculateRouteId !== undefined) {
|
|
31
|
+
yield* this.streamWithSpeculation(turn);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const routeId = await this.opts.classify(turn);
|
|
36
|
+
const route = this.resolveRoute(routeId);
|
|
37
|
+
this.metric("route.selected", routeId);
|
|
38
|
+
const iter = route.reasoner.stream({ ...turn, signal: turn.signal })[Symbol.asyncIterator]();
|
|
39
|
+
yield* this.forwardRoute(iter, undefined, turn.signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async *streamWithSpeculation(turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
43
|
+
const specId = this.opts.speculateRouteId!;
|
|
44
|
+
const specRoute = this.resolveRoute(specId);
|
|
45
|
+
|
|
46
|
+
const child = new AbortController();
|
|
47
|
+
if (turn.signal.aborted) {
|
|
48
|
+
child.abort();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
turn.signal.addEventListener("abort", () => child.abort(), { once: true });
|
|
52
|
+
|
|
53
|
+
const specIter = specRoute.reasoner.stream({ ...turn, signal: child.signal })[Symbol.asyncIterator]();
|
|
54
|
+
const specNext = specIter.next();
|
|
55
|
+
|
|
56
|
+
const classifiedId = await this.opts.classify(turn);
|
|
57
|
+
|
|
58
|
+
if (classifiedId === specId) {
|
|
59
|
+
this.metric("route.selected", classifiedId);
|
|
60
|
+
yield* this.forwardRoute(specIter, specNext, turn.signal);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child.abort();
|
|
65
|
+
releaseIterator(specIter);
|
|
66
|
+
void specNext.catch(() => undefined);
|
|
67
|
+
this.metric("route.mispredict", "1");
|
|
68
|
+
|
|
69
|
+
const route = this.resolveRoute(classifiedId);
|
|
70
|
+
this.metric("route.selected", classifiedId);
|
|
71
|
+
const iter = route.reasoner.stream({ ...turn, signal: turn.signal })[Symbol.asyncIterator]();
|
|
72
|
+
yield* this.forwardRoute(iter, undefined, turn.signal);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async *forwardRoute(
|
|
76
|
+
iter: AsyncIterator<ReasoningPart>,
|
|
77
|
+
first: Promise<IteratorResult<ReasoningPart>> | undefined,
|
|
78
|
+
signal: AbortSignal,
|
|
79
|
+
): AsyncGenerator<ReasoningPart> {
|
|
80
|
+
try {
|
|
81
|
+
let next = first ? await first : await iter.next();
|
|
82
|
+
while (!next.done) {
|
|
83
|
+
if (signal.aborted) return;
|
|
84
|
+
yield next.value;
|
|
85
|
+
next = await iter.next();
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
yield {
|
|
89
|
+
type: "error",
|
|
90
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
91
|
+
recoverable: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private resolveRoute(id: string): ReasonerRoute {
|
|
97
|
+
const route = this.opts.routes.find((r) => r.id === id);
|
|
98
|
+
if (!route) {
|
|
99
|
+
throw new Error(`RoutingReasoner: unknown route id "${id}"`);
|
|
100
|
+
}
|
|
101
|
+
return route;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private metric(name: string, value: string): void {
|
|
105
|
+
this.opts.bus?.push(Route.Background, make.metric(this.opts.contextId ?? "", name, value));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function releaseIterator(iter: AsyncIterator<ReasoningPart> | null): void {
|
|
110
|
+
if (!iter) return;
|
|
111
|
+
const release = iter.return;
|
|
112
|
+
if (release) void release.call(iter, undefined);
|
|
113
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
});
|
package/src/turn-arbiter.ts
CHANGED
|
@@ -64,6 +64,7 @@ export interface TurnArbiterDeps {
|
|
|
64
64
|
readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
65
65
|
readonly ttsPlayout: TtsPlayoutClock;
|
|
66
66
|
readonly minInterruptionMs: number;
|
|
67
|
+
readonly onInterrupt?: (interruptedContextId: string, source: "vad") => void;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export class TurnArbiter {
|
|
@@ -92,7 +93,7 @@ export class TurnArbiter {
|
|
|
92
93
|
return;
|
|
93
94
|
}
|
|
94
95
|
bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
|
|
95
|
-
this.
|
|
96
|
+
this.decideInterrupt(interruptedContextId);
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -256,11 +257,19 @@ export class TurnArbiter {
|
|
|
256
257
|
Route.Background,
|
|
257
258
|
make.metric(pending.interruptedContextId, "interrupt.latency_ms", String(sustainedMs)),
|
|
258
259
|
);
|
|
259
|
-
this.
|
|
260
|
+
this.decideInterrupt(pending.interruptedContextId);
|
|
260
261
|
this.latestInterimText = "";
|
|
261
262
|
this.latestInterimConfidence = null;
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
private decideInterrupt(interruptedContextId: string): void {
|
|
266
|
+
if (this.deps.onInterrupt) {
|
|
267
|
+
this.deps.onInterrupt(interruptedContextId, "vad");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.emitInterruptDetected(interruptedContextId);
|
|
271
|
+
}
|
|
272
|
+
|
|
264
273
|
private suppress(
|
|
265
274
|
pending: PendingTurnInterruption,
|
|
266
275
|
metricName: string,
|