@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,150 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import {
|
|
5
|
-
PrimarySpeakerGate,
|
|
6
|
-
extractSpeakerFingerprint,
|
|
7
|
-
fingerprintSimilarity,
|
|
8
|
-
} from "./primary-speaker-gate.js";
|
|
9
|
-
import * as primarySpeakerGateModule from "./primary-speaker-gate.js";
|
|
10
|
-
import {
|
|
11
|
-
ASSISTANT_ECHO_TONE_HZ,
|
|
12
|
-
BYSTANDER_SPEAKER_TONE_HZ,
|
|
13
|
-
PRIMARY_SPEAKER_TONE_HZ,
|
|
14
|
-
mixPcm16,
|
|
15
|
-
synthesizeTonePcm16,
|
|
16
|
-
} from "./primary-speaker-fixtures.js";
|
|
17
|
-
|
|
18
|
-
describe("extractSpeakerFingerprint", () => {
|
|
19
|
-
it("separates distinct synthetic speakers by band shape", () => {
|
|
20
|
-
const primary = extractSpeakerFingerprint(
|
|
21
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 40 }),
|
|
22
|
-
);
|
|
23
|
-
const bystander = extractSpeakerFingerprint(
|
|
24
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 40 }),
|
|
25
|
-
);
|
|
26
|
-
expect(primary).not.toBeNull();
|
|
27
|
-
expect(bystander).not.toBeNull();
|
|
28
|
-
const self = fingerprintSimilarity(primary!, primary!);
|
|
29
|
-
const cross = fingerprintSimilarity(primary!, bystander!);
|
|
30
|
-
expect(self).toBeGreaterThan(0.95);
|
|
31
|
-
expect(cross).toBeLessThan(0.72);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("PrimarySpeakerGate", () => {
|
|
36
|
-
it("commits barge-in for primary-only sustained speech", () => {
|
|
37
|
-
const gate = new PrimarySpeakerGate();
|
|
38
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
39
|
-
gate.enrollUserTurnChunk(primary);
|
|
40
|
-
gate.lockProfileFromFirstTurn();
|
|
41
|
-
gate.beginBargeInWindow();
|
|
42
|
-
for (let i = 0; i < 6; i += 1) {
|
|
43
|
-
gate.observeBargeInChunk(primary);
|
|
44
|
-
}
|
|
45
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("suppresses bystander sustained speech", () => {
|
|
49
|
-
const gate = new PrimarySpeakerGate();
|
|
50
|
-
gate.enrollUserTurnChunk(
|
|
51
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
52
|
-
);
|
|
53
|
-
gate.lockProfileFromFirstTurn();
|
|
54
|
-
gate.beginBargeInWindow();
|
|
55
|
-
const bystander = synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
56
|
-
for (let i = 0; i < 8; i += 1) {
|
|
57
|
-
gate.observeBargeInChunk(bystander);
|
|
58
|
-
}
|
|
59
|
-
expect(gate.shouldCommitBargeIn()).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("suppresses assistant echo over primary profile", () => {
|
|
63
|
-
const gate = new PrimarySpeakerGate();
|
|
64
|
-
gate.enrollUserTurnChunk(
|
|
65
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
66
|
-
);
|
|
67
|
-
gate.lockProfileFromFirstTurn();
|
|
68
|
-
const echo = synthesizeTonePcm16({
|
|
69
|
-
frequencyHz: ASSISTANT_ECHO_TONE_HZ,
|
|
70
|
-
durationMs: 32,
|
|
71
|
-
amplitude: 0.2,
|
|
72
|
-
});
|
|
73
|
-
gate.observeAssistantPlayout(
|
|
74
|
-
synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
|
|
75
|
-
);
|
|
76
|
-
gate.beginBargeInWindow();
|
|
77
|
-
for (let i = 0; i < 8; i += 1) {
|
|
78
|
-
gate.observeBargeInChunk(echo);
|
|
79
|
-
}
|
|
80
|
-
expect(gate.shouldCommitBargeIn()).toBe(false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("falls back to permissive commit when no profile is locked", () => {
|
|
84
|
-
const gate = new PrimarySpeakerGate();
|
|
85
|
-
gate.beginBargeInWindow();
|
|
86
|
-
gate.observeBargeInChunk(
|
|
87
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
88
|
-
);
|
|
89
|
-
expect(gate.hasProfile()).toBe(false);
|
|
90
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("commits barge-in when primary similarity beats echo even above threshold", () => {
|
|
94
|
-
const gate = new PrimarySpeakerGate({ similarityThreshold: 0.72, echoDominanceMargin: 0.12 });
|
|
95
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
96
|
-
gate.enrollUserTurnChunk(primary);
|
|
97
|
-
gate.lockProfileFromFirstTurn();
|
|
98
|
-
gate.observeAssistantPlayout(
|
|
99
|
-
synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
const profile = (gate as unknown as { profile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>> }).profile;
|
|
103
|
-
const assistantProfile = (gate as unknown as {
|
|
104
|
-
assistantProfile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>>;
|
|
105
|
-
}).assistantProfile;
|
|
106
|
-
|
|
107
|
-
vi.spyOn(primarySpeakerGateModule, "fingerprintSimilarity").mockImplementation((_frame, reference) => {
|
|
108
|
-
if (reference === profile) return 0.95;
|
|
109
|
-
if (reference === assistantProfile) return 0.73;
|
|
110
|
-
return 0;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
gate.beginBargeInWindow();
|
|
114
|
-
for (let i = 0; i < 6; i += 1) {
|
|
115
|
-
gate.observeBargeInChunk(primary);
|
|
116
|
-
}
|
|
117
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
118
|
-
vi.restoreAllMocks();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("allows mixed audio when primary dominates", () => {
|
|
122
|
-
const gate = new PrimarySpeakerGate();
|
|
123
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
124
|
-
gate.enrollUserTurnChunk(primary);
|
|
125
|
-
gate.lockProfileFromFirstTurn();
|
|
126
|
-
const mixed = mixPcm16(
|
|
127
|
-
[
|
|
128
|
-
synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
|
|
129
|
-
synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32, amplitude: 0.08 }),
|
|
130
|
-
],
|
|
131
|
-
[1, 1],
|
|
132
|
-
);
|
|
133
|
-
gate.beginBargeInWindow();
|
|
134
|
-
for (let i = 0; i < 6; i += 1) {
|
|
135
|
-
gate.observeBargeInChunk(mixed);
|
|
136
|
-
}
|
|
137
|
-
expect(gate.shouldCommitBargeIn()).toBe(true);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("mixes PCM chunks whose byteOffset is odd", () => {
|
|
141
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
142
|
-
const backing = new Uint8Array(primary.byteLength + 1);
|
|
143
|
-
backing.set(primary, 1);
|
|
144
|
-
const oddOffsetPrimary = backing.subarray(1);
|
|
145
|
-
expect(oddOffsetPrimary.byteOffset % 2).toBe(1);
|
|
146
|
-
|
|
147
|
-
expect(() => mixPcm16([oddOffsetPrimary], [1])).not.toThrow();
|
|
148
|
-
expect(mixPcm16([oddOffsetPrimary], [1])).toEqual(primary);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { PipelineBusImpl } from "./pipeline-bus.js";
|
|
5
|
-
import { ProviderFallback, type FallbackProvider } from "./provider-fallback.js";
|
|
6
|
-
import type { ConversationMetricPacket } from "./packets.js";
|
|
7
|
-
|
|
8
|
-
function provider(
|
|
9
|
-
id: string,
|
|
10
|
-
send: FallbackProvider<string, string>["send"],
|
|
11
|
-
healthProbe: FallbackProvider<string, string>["healthProbe"] = async () => true,
|
|
12
|
-
): FallbackProvider<string, string> {
|
|
13
|
-
return { id, send, healthProbe };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe("ProviderFallback", () => {
|
|
17
|
-
it("falls through unavailable providers and emits availability metrics", async () => {
|
|
18
|
-
const bus = new PipelineBusImpl();
|
|
19
|
-
const started = bus.start();
|
|
20
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
21
|
-
bus.on("metric.conversation", (pkt) => {
|
|
22
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
23
|
-
});
|
|
24
|
-
const first = provider("stt.primary", async () => {
|
|
25
|
-
throw new Error("primary down");
|
|
26
|
-
});
|
|
27
|
-
const second = provider("stt.backup", async (req) => `backup:${req}`);
|
|
28
|
-
const fallback = new ProviderFallback([first, second], {
|
|
29
|
-
bus,
|
|
30
|
-
contextId: "turn-1",
|
|
31
|
-
attemptTimeoutMs: 100,
|
|
32
|
-
recoveryProbeIntervalMs: 1000,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
await expect(fallback.send("hello")).resolves.toBe("backup:hello");
|
|
36
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
37
|
-
|
|
38
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
39
|
-
contextId: "turn-1",
|
|
40
|
-
name: "stt.primary.availability_changed",
|
|
41
|
-
value: "unavailable",
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
fallback.close();
|
|
45
|
-
bus.stop();
|
|
46
|
-
await started;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("runs background recovery probes and returns recovered providers to service", async () => {
|
|
50
|
-
const bus = new PipelineBusImpl();
|
|
51
|
-
const started = bus.start();
|
|
52
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
53
|
-
bus.on("metric.conversation", (pkt) => {
|
|
54
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
55
|
-
});
|
|
56
|
-
let primaryHealthy = false;
|
|
57
|
-
const first = provider(
|
|
58
|
-
"tts.primary",
|
|
59
|
-
async () => {
|
|
60
|
-
if (!primaryHealthy) throw new Error("primary down");
|
|
61
|
-
return "primary";
|
|
62
|
-
},
|
|
63
|
-
async () => primaryHealthy,
|
|
64
|
-
);
|
|
65
|
-
const second = provider("tts.backup", async () => "backup");
|
|
66
|
-
const fallback = new ProviderFallback([first, second], {
|
|
67
|
-
bus,
|
|
68
|
-
contextId: "turn-2",
|
|
69
|
-
attemptTimeoutMs: 100,
|
|
70
|
-
recoveryProbeIntervalMs: 10,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await expect(fallback.send("hello")).resolves.toBe("backup");
|
|
74
|
-
primaryHealthy = true;
|
|
75
|
-
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
76
|
-
await expect(fallback.send("hello")).resolves.toBe("primary");
|
|
77
|
-
|
|
78
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
79
|
-
name: "tts.primary.availability_changed",
|
|
80
|
-
value: "available",
|
|
81
|
-
}));
|
|
82
|
-
|
|
83
|
-
fallback.close();
|
|
84
|
-
bus.stop();
|
|
85
|
-
await started;
|
|
86
|
-
});
|
|
87
|
-
});
|
|
@@ -1,361 +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 { HedgedReasoner } from "./reasoner-hedge.js";
|
|
9
|
-
import type { ScheduledCallback, Scheduler } from "./scheduler.js";
|
|
10
|
-
|
|
11
|
-
class FakeScheduler implements Scheduler {
|
|
12
|
-
private readonly callbacks = new Map<string, ScheduledCallback>();
|
|
13
|
-
|
|
14
|
-
schedule(key: string, _delayMs: number, cb: ScheduledCallback): void {
|
|
15
|
-
this.callbacks.set(key, cb);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
cancel(key: string): void {
|
|
19
|
-
this.callbacks.delete(key);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
fire(key: string): void {
|
|
23
|
-
const cb = this.callbacks.get(key);
|
|
24
|
-
if (!cb) return;
|
|
25
|
-
this.callbacks.delete(key);
|
|
26
|
-
void cb();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
has(key: string): boolean {
|
|
30
|
-
return this.callbacks.has(key);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
class ControllableReasoner implements Reasoner {
|
|
35
|
-
streamInvoked = false;
|
|
36
|
-
capturedSignal: AbortSignal | undefined;
|
|
37
|
-
private deliver: ((part: ReasoningPart) => void) | null = null;
|
|
38
|
-
|
|
39
|
-
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
40
|
-
this.streamInvoked = true;
|
|
41
|
-
this.capturedSignal = turn.signal;
|
|
42
|
-
const queue: ReasoningPart[] = [];
|
|
43
|
-
let wake: (() => void) | null = null;
|
|
44
|
-
|
|
45
|
-
this.deliver = (part: ReasoningPart) => {
|
|
46
|
-
queue.push(part);
|
|
47
|
-
wake?.();
|
|
48
|
-
wake = null;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
async function* generator(): AsyncGenerator<ReasoningPart> {
|
|
52
|
-
while (!turn.signal.aborted) {
|
|
53
|
-
if (queue.length === 0) {
|
|
54
|
-
await new Promise<void>((resolve) => {
|
|
55
|
-
if (turn.signal.aborted) {
|
|
56
|
-
resolve();
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
wake = resolve;
|
|
60
|
-
turn.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
if (turn.signal.aborted) return;
|
|
64
|
-
const part = queue.shift();
|
|
65
|
-
if (part === undefined) return;
|
|
66
|
-
yield part;
|
|
67
|
-
if (part.type === "error" || part.type === "suspended" || part.type === "finish") return;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return generator();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
emit(part: ReasoningPart): void {
|
|
75
|
-
this.deliver?.(part);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function baseTurn(signal = new AbortController().signal): ReasonerTurn {
|
|
80
|
-
return { userText: "hi", messages: [{ role: "user", content: "hi" }], signal };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function collect(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
84
|
-
const parts: ReasoningPart[] = [];
|
|
85
|
-
for await (const part of reasoner.stream(turn)) {
|
|
86
|
-
parts.push(part);
|
|
87
|
-
}
|
|
88
|
-
return parts;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function silentReasoner(hook?: (turn: ReasonerTurn) => void): Reasoner {
|
|
92
|
-
return {
|
|
93
|
-
stream(turn) {
|
|
94
|
-
hook?.(turn);
|
|
95
|
-
return (async function*() {
|
|
96
|
-
await new Promise<void>(() => {});
|
|
97
|
-
})();
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function scriptedReasoner(
|
|
103
|
-
parts: readonly ReasoningPart[],
|
|
104
|
-
hook?: (turn: ReasonerTurn) => void,
|
|
105
|
-
): Reasoner {
|
|
106
|
-
return {
|
|
107
|
-
stream(turn) {
|
|
108
|
-
hook?.(turn);
|
|
109
|
-
return (async function*() {
|
|
110
|
-
for (const part of parts) {
|
|
111
|
-
if (turn.signal.aborted) return;
|
|
112
|
-
yield part;
|
|
113
|
-
}
|
|
114
|
-
})();
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
describe("HedgedReasoner", () => {
|
|
120
|
-
it("(a) primary fast — backup never started", async () => {
|
|
121
|
-
const primary = new ControllableReasoner();
|
|
122
|
-
const backup = new ControllableReasoner();
|
|
123
|
-
const scheduler = new FakeScheduler();
|
|
124
|
-
const hedge = new HedgedReasoner({
|
|
125
|
-
primary,
|
|
126
|
-
backup,
|
|
127
|
-
hedgeAfterMs: 50,
|
|
128
|
-
scheduler,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
132
|
-
primary.emit({ type: "text-delta", text: "hello" });
|
|
133
|
-
primary.emit({ type: "finish", reason: "stop", text: "hello" });
|
|
134
|
-
|
|
135
|
-
const parts = await streamPromise;
|
|
136
|
-
|
|
137
|
-
expect(backup.streamInvoked).toBe(false);
|
|
138
|
-
expect(scheduler.has("hedge")).toBe(false);
|
|
139
|
-
expect(parts).toEqual([
|
|
140
|
-
{ type: "text-delta", text: "hello" },
|
|
141
|
-
{ type: "finish", reason: "stop", text: "hello" },
|
|
142
|
-
]);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("(b) hedge fires, primary still wins", async () => {
|
|
146
|
-
const primary = new ControllableReasoner();
|
|
147
|
-
const backup = new ControllableReasoner();
|
|
148
|
-
const scheduler = new FakeScheduler();
|
|
149
|
-
const bus = new PipelineBusImpl();
|
|
150
|
-
const started = bus.start();
|
|
151
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
152
|
-
bus.on("metric.conversation", (pkt) => {
|
|
153
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const hedge = new HedgedReasoner({
|
|
157
|
-
primary,
|
|
158
|
-
backup,
|
|
159
|
-
hedgeAfterMs: 50,
|
|
160
|
-
scheduler,
|
|
161
|
-
bus,
|
|
162
|
-
contextId: "ctx-1",
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
166
|
-
await Promise.resolve();
|
|
167
|
-
scheduler.fire("hedge");
|
|
168
|
-
await Promise.resolve();
|
|
169
|
-
expect(backup.streamInvoked).toBe(true);
|
|
170
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
|
|
171
|
-
|
|
172
|
-
primary.emit({ type: "text-delta", text: "primary" });
|
|
173
|
-
backup.emit({ type: "text-delta", text: "backup" });
|
|
174
|
-
primary.emit({ type: "finish", reason: "stop", text: "primary" });
|
|
175
|
-
|
|
176
|
-
const parts = await streamPromise;
|
|
177
|
-
|
|
178
|
-
expect(parts).toEqual([
|
|
179
|
-
{ type: "text-delta", text: "primary" },
|
|
180
|
-
{ type: "finish", reason: "stop", text: "primary" },
|
|
181
|
-
]);
|
|
182
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "primary" }));
|
|
183
|
-
expect(backup.capturedSignal?.aborted).toBe(true);
|
|
184
|
-
|
|
185
|
-
bus.stop();
|
|
186
|
-
await started;
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("(c) backup wins — no interleaving", async () => {
|
|
190
|
-
let primarySignal: AbortSignal | undefined;
|
|
191
|
-
let backupStarted = false;
|
|
192
|
-
const scheduler = new FakeScheduler();
|
|
193
|
-
const hedge = new HedgedReasoner({
|
|
194
|
-
primary: silentReasoner((turn) => {
|
|
195
|
-
primarySignal = turn.signal;
|
|
196
|
-
}),
|
|
197
|
-
backup: scriptedReasoner(
|
|
198
|
-
[
|
|
199
|
-
{ type: "text-delta", text: "backup" },
|
|
200
|
-
{ type: "finish", reason: "stop", text: "backup" },
|
|
201
|
-
],
|
|
202
|
-
() => {
|
|
203
|
-
backupStarted = true;
|
|
204
|
-
},
|
|
205
|
-
),
|
|
206
|
-
hedgeAfterMs: 10,
|
|
207
|
-
scheduler,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
211
|
-
await Promise.resolve();
|
|
212
|
-
scheduler.fire("hedge");
|
|
213
|
-
const parts = await streamPromise;
|
|
214
|
-
|
|
215
|
-
expect(backupStarted).toBe(true);
|
|
216
|
-
expect(parts).toEqual([
|
|
217
|
-
{ type: "text-delta", text: "backup" },
|
|
218
|
-
{ type: "finish", reason: "stop", text: "backup" },
|
|
219
|
-
]);
|
|
220
|
-
expect(primarySignal?.aborted).toBe(true);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("(d) loser aborted after commit", async () => {
|
|
224
|
-
let primarySignal: AbortSignal | undefined;
|
|
225
|
-
let backupSignal: AbortSignal | undefined;
|
|
226
|
-
const scheduler = new FakeScheduler();
|
|
227
|
-
const hedge = new HedgedReasoner({
|
|
228
|
-
primary: silentReasoner((turn) => {
|
|
229
|
-
primarySignal = turn.signal;
|
|
230
|
-
}),
|
|
231
|
-
backup: scriptedReasoner(
|
|
232
|
-
[
|
|
233
|
-
{ type: "text-delta", text: "backup" },
|
|
234
|
-
{ type: "finish", reason: "stop", text: "backup" },
|
|
235
|
-
],
|
|
236
|
-
(turn) => {
|
|
237
|
-
backupSignal = turn.signal;
|
|
238
|
-
},
|
|
239
|
-
),
|
|
240
|
-
hedgeAfterMs: 5,
|
|
241
|
-
scheduler,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
245
|
-
await Promise.resolve();
|
|
246
|
-
scheduler.fire("hedge");
|
|
247
|
-
await streamPromise;
|
|
248
|
-
|
|
249
|
-
expect(primarySignal?.aborted).toBe(true);
|
|
250
|
-
expect(backupSignal?.aborted).toBe(false);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("(e) pre-commit primary error fails over to backup", async () => {
|
|
254
|
-
let backupStarted = false;
|
|
255
|
-
const hedge = new HedgedReasoner({
|
|
256
|
-
primary: scriptedReasoner([
|
|
257
|
-
{
|
|
258
|
-
type: "error",
|
|
259
|
-
cause: new Error("primary down"),
|
|
260
|
-
recoverable: true,
|
|
261
|
-
},
|
|
262
|
-
]),
|
|
263
|
-
backup: scriptedReasoner(
|
|
264
|
-
[
|
|
265
|
-
{ type: "text-delta", text: "recovered" },
|
|
266
|
-
{ type: "finish", reason: "stop", text: "recovered" },
|
|
267
|
-
],
|
|
268
|
-
() => {
|
|
269
|
-
backupStarted = true;
|
|
270
|
-
},
|
|
271
|
-
),
|
|
272
|
-
hedgeAfterMs: 100,
|
|
273
|
-
scheduler: new FakeScheduler(),
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
const parts = await collect(hedge, baseTurn());
|
|
277
|
-
|
|
278
|
-
expect(backupStarted).toBe(true);
|
|
279
|
-
expect(parts).toEqual([
|
|
280
|
-
{ type: "text-delta", text: "recovered" },
|
|
281
|
-
{ type: "finish", reason: "stop", text: "recovered" },
|
|
282
|
-
]);
|
|
283
|
-
expect(parts.some((p) => p.type === "error")).toBe(false);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("(f) post-commit error forwarded verbatim", async () => {
|
|
287
|
-
const primary = new ControllableReasoner();
|
|
288
|
-
const backup = new ControllableReasoner();
|
|
289
|
-
const hedge = new HedgedReasoner({
|
|
290
|
-
primary,
|
|
291
|
-
backup,
|
|
292
|
-
hedgeAfterMs: 100,
|
|
293
|
-
scheduler: new FakeScheduler(),
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
297
|
-
primary.emit({ type: "text-delta", text: "partial" });
|
|
298
|
-
const err: ReasoningPart = {
|
|
299
|
-
type: "error",
|
|
300
|
-
cause: new Error("mid-stream"),
|
|
301
|
-
recoverable: false,
|
|
302
|
-
};
|
|
303
|
-
primary.emit(err);
|
|
304
|
-
backup.emit({ type: "text-delta", text: "backup-late" });
|
|
305
|
-
|
|
306
|
-
const parts = await streamPromise;
|
|
307
|
-
|
|
308
|
-
expect(parts).toEqual([
|
|
309
|
-
{ type: "text-delta", text: "partial" },
|
|
310
|
-
err,
|
|
311
|
-
]);
|
|
312
|
-
expect(backup.streamInvoked).toBe(false);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it("(g) metrics — fired iff backup started, committed_to reflects winner; no bus is safe", async () => {
|
|
316
|
-
const scheduler = new FakeScheduler();
|
|
317
|
-
const bus = new PipelineBusImpl();
|
|
318
|
-
const started = bus.start();
|
|
319
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
320
|
-
bus.on("metric.conversation", (pkt) => {
|
|
321
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
const hedge = new HedgedReasoner({
|
|
325
|
-
primary: silentReasoner(),
|
|
326
|
-
backup: scriptedReasoner([
|
|
327
|
-
{ type: "text-delta", text: "b" },
|
|
328
|
-
{ type: "finish", reason: "stop", text: "b" },
|
|
329
|
-
]),
|
|
330
|
-
hedgeAfterMs: 10,
|
|
331
|
-
scheduler,
|
|
332
|
-
bus,
|
|
333
|
-
contextId: "ctx-m",
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
const streamPromise = collect(hedge, baseTurn());
|
|
337
|
-
await Promise.resolve();
|
|
338
|
-
scheduler.fire("hedge");
|
|
339
|
-
await streamPromise;
|
|
340
|
-
|
|
341
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
|
|
342
|
-
expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "backup" }));
|
|
343
|
-
|
|
344
|
-
const noBusPrimary = new ControllableReasoner();
|
|
345
|
-
const noBusBackup = new ControllableReasoner();
|
|
346
|
-
const noBusHedge = new HedgedReasoner({
|
|
347
|
-
primary: noBusPrimary,
|
|
348
|
-
backup: noBusBackup,
|
|
349
|
-
hedgeAfterMs: 10,
|
|
350
|
-
scheduler: new FakeScheduler(),
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
const noBusPromise = collect(noBusHedge, baseTurn());
|
|
354
|
-
noBusPrimary.emit({ type: "text-delta", text: "ok" });
|
|
355
|
-
noBusPrimary.emit({ type: "finish", reason: "stop", text: "ok" });
|
|
356
|
-
await expect(noBusPromise).resolves.toHaveLength(2);
|
|
357
|
-
|
|
358
|
-
bus.stop();
|
|
359
|
-
await started;
|
|
360
|
-
});
|
|
361
|
-
});
|